From 823df4242d0cf3532ad5f33c3d5caef5bb709191 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 0001/1435] 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 46cef2986c531e2f2d530fa474e4796b067f65d9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 19:32:36 +0100 Subject: [PATCH 0002/1435] Bump version to 2025.3.0 (#136859) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a58648212e3..863c861db75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.2" + HA_SHORT_VERSION: "2025.3" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 699aebcafdf..bdce303e64a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 2 +MINOR_VERSION: Final = 3 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 5393193a41e..31aeb180b8c 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.3.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b500fde46843ac29e81a979ce366b221b3ab0fa8 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 0003/1435] 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 d206553a0da4bcafa2e840cebd29bd4baeba25bc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Jan 2025 12:52:32 -0600 Subject: [PATCH 0004/1435] 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 5286bd8f0c65deaa313cafa02b5b334ebc23c4c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 19:55:02 +0100 Subject: [PATCH 0005/1435] 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 edabf0f8dd5b52132f4043c686f63a1ba9f4b70f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jan 2025 09:09:00 -1000 Subject: [PATCH 0006/1435] 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 aca9607e2fb7b35d62c1179ce79a3d484a6a0437 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 21:58:06 +0100 Subject: [PATCH 0007/1435] 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 40662896621a377a09796ba73385d8d9b033b364 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:32:16 +0100 Subject: [PATCH 0008/1435] Update quality scale in Onkyo (#136710) --- homeassistant/components/onkyo/quality_scale.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index cdcf88e72d7..4b9fbe7c019 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -16,7 +16,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: | @@ -45,8 +45,8 @@ rules: # Gold devices: todo diagnostics: todo - discovery: todo - discovery-update-info: todo + discovery: done + discovery-update-info: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo From 4e3e1e91b7adac7142870ed401c3eeee06c1ad58 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 12:01:39 +1100 Subject: [PATCH 0009/1435] 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 b637129208e45620a1e6d264b92844f16787f49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 30 Jan 2025 02:42:41 +0100 Subject: [PATCH 0010/1435] Migrate from homeconnect dependency to aiohomeconnect (#136116) * Migrate from homeconnect dependency to aiohomeconnect * Reload the integration if there is an API error on event stream * fix typos at coordinator tests * Setup config entry at coordinator tests * fix ruff * Bump aiohomeconnect to version 0.11.4 * Fix set program options * Use context based updates at coordinator * Improved how `context_callbacks` cache is invalidated * fix * fixes and improvements at coordinator Co-authored-by: Martin Hjelmare * Remove stale Entity inheritance * Small improvement for light subscriptions * Remove non-needed function It had its purpose before some refactoring before the firs commit, no is no needed as is only used at HomeConnectEntity constructor * Static methods and variables at conftest * Refresh the data after an event stream interruption * Cleaned debug logs * Fetch programs at coordinator * Improvements Co-authored-by: Martin Hjelmare * Simplify obtaining power settings from coordinator data Co-authored-by: Martin Hjelmare * Remove unnecessary statement * use `is UNDEFINED` instead of `isinstance` * Request power setting only when it is strictly necessary * Bump aiohomeconnect to 0.12.1 * use raw keys for diagnostics * Use keyword arguments where needed * Remove unnecessary statements Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 325 +++++----- homeassistant/components/home_connect/api.py | 79 +-- .../home_connect/application_credentials.py | 4 +- .../components/home_connect/binary_sensor.py | 102 ++- .../components/home_connect/const.py | 129 +--- .../components/home_connect/coordinator.py | 258 ++++++++ .../components/home_connect/diagnostics.py | 49 +- .../components/home_connect/entity.py | 59 +- .../components/home_connect/light.py | 241 +++---- .../components/home_connect/manifest.json | 2 +- .../components/home_connect/number.py | 101 +-- .../components/home_connect/select.py | 278 ++------- .../components/home_connect/sensor.py | 224 ++++--- .../components/home_connect/strings.json | 39 +- .../components/home_connect/switch.py | 290 ++++----- homeassistant/components/home_connect/time.py | 61 +- .../components/home_connect/utils.py | 29 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/home_connect/conftest.py | 380 ++++++----- .../home_connect/fixtures/settings.json | 24 +- .../snapshots/test_diagnostics.ambr | 588 +++++++----------- .../home_connect/test_binary_sensor.py | 145 +++-- .../home_connect/test_config_flow.py | 7 +- .../home_connect/test_coordinator.py | 367 +++++++++++ .../home_connect/test_diagnostics.py | 90 +-- tests/components/home_connect/test_init.py | 220 ++++--- tests/components/home_connect/test_light.py | 400 +++++++----- tests/components/home_connect/test_number.py | 154 +++-- tests/components/home_connect/test_select.py | 202 +++--- tests/components/home_connect/test_sensor.py | 249 +++++--- tests/components/home_connect/test_switch.py | 548 +++++++++------- tests/components/home_connect/test_time.py | 102 ++- 33 files changed, 3117 insertions(+), 2641 deletions(-) create mode 100644 homeassistant/components/home_connect/coordinator.py create mode 100644 homeassistant/components/home_connect/utils.py create mode 100644 tests/components/home_connect/test_coordinator.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index d7c042c2a91..a019ae0f250 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,17 +2,16 @@ from __future__ import annotations -from datetime import timedelta import logging -import re from typing import Any, cast -from requests import HTTPError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import CommandKey, Option, OptionKey +from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, @@ -21,16 +20,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -from . import api +from .api import AsyncConfigEntryAuth from .const import ( ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, - BSH_PAUSE, - BSH_RESUME, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, SERVICE_OPTION_ACTIVE, @@ -44,15 +40,11 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) - -type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] +from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectAppliance: - """Return a Home Connect appliance instance given a device id or a device entry.""" - if device_id is not None and device_entry is None: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - assert device_entry, "Either a device id or a device entry must be provided" +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError("Device entry not found for device id") + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + "Home Connect config entry not found for that device id" + ) ha_id = next( ( @@ -119,158 +118,148 @@ def _get_appliance( ), None, ) - assert ha_id - - def find_appliance( - entry: HomeConnectConfigEntry, - ) -> api.HomeConnectAppliance | None: - for device in entry.runtime_data.devices: - appliance = device.appliance - if appliance.haId == ha_id: - return appliance - return None - - if entry is None: - for entry_id in device_entry.config_entries: - entry = hass.config_entries.async_get_entry(entry_id) - assert entry - if entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, entry) - if (appliance := find_appliance(entry)) is not None: - return appliance - elif (appliance := find_appliance(entry)) is not None: - return appliance - raise ValueError(f"Appliance for device id {device_entry.id} not found") - - -def _get_appliance_or_raise_service_validation_error( - hass: HomeAssistant, device_id: str -) -> api.HomeConnectAppliance: - """Return a Home Connect appliance instance or raise a service validation error.""" - try: - return _get_appliance(hass, device_id) - except (ValueError, AssertionError) as err: + if ha_id is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="appliance_not_found", translation_placeholders={ "device_id": device_id, }, - ) from err - - -async def _run_appliance_service[*_Ts]( - hass: HomeAssistant, - appliance: api.HomeConnectAppliance, - method: str, - *args: *_Ts, - error_translation_key: str, - error_translation_placeholders: dict[str, str], -) -> None: - try: - await hass.async_add_executor_job(getattr(appliance, method), *args) - except api.HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=error_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **error_translation_placeholders, - }, - ) from err + ) + return entry.runtime_data.client, ha_id async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - async def _async_service_program(call, method): + async def _async_service_program(call: ServiceCall, start: bool): """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] - device_id = call.data[ATTR_DEVICE_ID] - - options = [] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) option_key = call.data.get(ATTR_KEY) - if option_key is not None: - option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]} - - option_unit = call.data.get(ATTR_UNIT) - if option_unit is not None: - option[ATTR_UNIT] = option_unit - - options.append(option) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - program, - options, - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, - }, + options = ( + [ + Option( + OptionKey(option_key), + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None ) - async def _async_service_command(call, command): - """Execute calls to services executing a command.""" - device_id = call.data[ATTR_DEVICE_ID] + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) from err - appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) - await _run_appliance_service( - hass, - appliance, - "execute_command", - command, - error_translation_key="execute_command", - error_translation_placeholders={"command": command}, - ) - - async def _async_service_key_value(call, method): - """Execute calls to services taking a key and value.""" - key = call.data[ATTR_KEY] + async def _async_service_set_program_options(call: ServiceCall, active: bool): + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] value = call.data[ATTR_VALUE] unit = call.data.get(ATTR_UNIT) - device_id = call.data[ATTR_DEVICE_ID] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - *((key, value) if unit is None else (key, value, unit)), - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err - async def async_service_option_active(call): + async def _async_service_command(call: ServiceCall, command_key: CommandKey): + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + async def async_service_option_active(call: ServiceCall): """Service for setting an option for an active program.""" - await _async_service_key_value(call, "set_options_active_program") + await _async_service_set_program_options(call, True) - async def async_service_option_selected(call): + async def async_service_option_selected(call: ServiceCall): """Service for setting an option for a selected program.""" - await _async_service_key_value(call, "set_options_selected_program") + await _async_service_set_program_options(call, False) - async def async_service_setting(call): + async def async_service_setting(call: ServiceCall): """Service for changing a setting.""" - await _async_service_key_value(call, "set_setting") + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - async def async_service_pause_program(call): + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err + + async def async_service_pause_program(call: ServiceCall): """Service for pausing a program.""" - await _async_service_command(call, BSH_PAUSE) + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - async def async_service_resume_program(call): + async def async_service_resume_program(call: ServiceCall): """Service for resuming a paused program.""" - await _async_service_command(call, BSH_RESUME) + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - async def async_service_select_program(call): + async def async_service_select_program(call: ServiceCall): """Service for selecting a program.""" - await _async_service_program(call, "select_program") + await _async_service_program(call, False) - async def async_service_start_program(call): + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" - await _async_service_program(call, "start_program") + await _async_service_program(call, True) hass.services.async_register( DOMAIN, @@ -323,12 +312,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) ) ) - entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - await update_all_devices(hass, entry) + config_entry_auth = AsyncConfigEntryAuth(hass, session) + + home_connect_client = HomeConnectClient(config_entry_auth) + + coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.runtime_data.start_event_listener() + return True @@ -339,21 +337,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -@Throttle(SCAN_INTERVAL) -async def update_all_devices( - hass: HomeAssistant, entry: HomeConnectConfigEntry -) -> None: - """Update all the devices.""" - hc_api = entry.runtime_data - - try: - await hass.async_add_executor_job(hc_api.get_devices) - for device in hc_api.devices: - await hass.async_add_executor_job(device.initialize) - except HTTPError as err: - _LOGGER.warning("Cannot update devices: %s", err.response.status_code) - - async def async_migrate_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: @@ -382,25 +365,3 @@ async def async_migrate_entry( _LOGGER.debug("Migration to version %s successful", entry.version) return True - - -def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]: - """Return a dict from a Home Connect error.""" - return { - "description": cast(dict[str, Any], err.args[0]).get("description", "?") - if len(err.args) > 0 and isinstance(err.args[0], dict) - else err.args[0] - if len(err.args) > 0 and isinstance(err.args[0], str) - else "?", - } - - -def bsh_key_to_translation_key(bsh_key: str) -> str: - """Convert a BSH key to a translation key format. - - This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, - and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. - """ - return "_".join( - RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") - ).lower() diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 453f926c402..5d711dae032 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -1,85 +1,28 @@ """API for Home Connect bound to HASS OAuth.""" -from asyncio import run_coroutine_threadsafe -import logging +from aiohomeconnect.client import AbstractAuth +from aiohomeconnect.const import API_ENDPOINT -import homeconnect -from homeconnect.api import HomeConnectAppliance, HomeConnectError - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.dispatcher import dispatcher_send - -from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES - -_LOGGER = logging.getLogger(__name__) +from homeassistant.helpers.httpx_client import get_async_client -class ConfigEntryAuth(homeconnect.HomeConnectAPI): +class AsyncConfigEntryAuth(AbstractAuth): """Provide Home Connect authentication tied to an OAuth2 based config entry.""" def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Home Connect Auth.""" self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - super().__init__(self.session.token) - self.devices: list[HomeConnectDevice] = [] + super().__init__(get_async_client(hass), host=API_ENDPOINT) + self.session = oauth_session - def refresh_tokens(self) -> dict: - """Refresh and return new Home Connect tokens using Home Assistant OAuth2 session.""" - run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self.hass.loop - ).result() + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self.session.async_ensure_token_valid() - return self.session.token - - def get_devices(self) -> list[HomeConnectAppliance]: - """Get a dictionary of devices.""" - appl: list[HomeConnectAppliance] = self.get_appliances() - self.devices = [HomeConnectDevice(self.hass, app) for app in appl] - return self.devices - - -class HomeConnectDevice: - """Generic Home Connect device.""" - - def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None: - """Initialize the device class.""" - self.hass = hass - self.appliance = appliance - - def initialize(self) -> None: - """Fetch the info needed to initialize the device.""" - try: - self.appliance.get_status() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch appliance status. Probably offline") - try: - self.appliance.get_settings() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch settings. Probably offline") - try: - program_active = self.appliance.get_programs_active() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch active programs. Probably offline") - program_active = None - if program_active and ATTR_KEY in program_active: - self.appliance.status[BSH_ACTIVE_PROGRAM] = { - ATTR_VALUE: program_active[ATTR_KEY] - } - self.appliance.listen_events(callback=self.event_callback) - - def event_callback(self, appliance: HomeConnectAppliance) -> None: - """Handle event.""" - _LOGGER.debug("Update triggered on %s", appliance.name) - _LOGGER.debug(self.appliance.status) - dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId) + return self.session.token["access_token"] diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index 3d5a407b487..d66255e6810 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -1,10 +1,10 @@ """Application credentials platform for Home Connect.""" +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN - async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: """Return authorization server.""" diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index f9775918f16..90743c829e2 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -1,7 +1,9 @@ """Provides a binary sensor for Home Connect.""" from dataclasses import dataclass -import logging +from typing import cast + +from aiohomeconnect.model import StatusKey from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -19,26 +21,21 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from . import HomeConnectConfigEntry -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, - BSH_REMOTE_CONTROL_ACTIVATION_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, DOMAIN, - REFRIGERATION_STATUS_DOOR_CHILLER, REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_FREEZER, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, +) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, @@ -54,19 +51,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS = ( HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_CONTROL_ACTIVATION_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, translation_key="remote_control", ), HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_START_ALLOWANCE_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, translation_key="remote_start", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.LocalControlActive", + key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE, translation_key="local_control", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.BatteryChargingState", + key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, boolean_map={ "BSH.Common.EnumType.BatteryChargingState.Charging": True, @@ -75,7 +72,7 @@ BINARY_SENSORS = ( translation_key="battery_charging_state", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.ChargingConnection", + key=StatusKey.BSH_COMMON_CHARGING_CONNECTION, device_class=BinarySensorDeviceClass.PLUG, boolean_map={ "BSH.Common.EnumType.ChargingConnection.Connected": True, @@ -84,31 +81,31 @@ BINARY_SENSORS = ( translation_key="charging_connection", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED, translation_key="dust_box_inserted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lifted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED, translation_key="lifted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lost", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST, translation_key="lost", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_CHILLER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_FREEZER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="freezer_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="refrigerator_door", @@ -123,19 +120,17 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect binary sensor.""" - def get_entities() -> list[BinarySensorEntity]: - entities: list[BinarySensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectBinarySensor(device, description) - for description in BINARY_SENSORS - if description.key in device.appliance.status - ) - if BSH_DOOR_STATE in device.appliance.status: - entities.append(HomeConnectDoorBinarySensor(device)) - return entities + entities: list[BinarySensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectBinarySensor(entry.runtime_data, appliance, description) + for description in BINARY_SENSORS + if description.key in appliance.status + ) + if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: + entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): @@ -143,25 +138,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): entity_description: HomeConnectBinarySensorEntityDescription - @property - def available(self) -> bool: - """Return true if the binary sensor is available.""" - return self._attr_is_on is not None - - async def async_update(self) -> None: - """Update the binary sensor's status.""" - if not self.device.appliance.status or not ( - status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - ): - self._attr_is_on = None - return - if self.entity_description.boolean_map: - self._attr_is_on = self.entity_description.boolean_map.get(status) - elif status not in [True, False]: - self._attr_is_on = None - else: + def update_native_value(self) -> None: + """Set the native value of the binary sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + if isinstance(status, bool): self._attr_is_on = status - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + elif self.entity_description.boolean_map: + self._attr_is_on = self.entity_description.boolean_map.get(status) + else: + self._attr_is_on = None class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): @@ -171,13 +156,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): def __init__( self, - device: HomeConnectDevice, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, HomeConnectBinarySensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=BinarySensorDeviceClass.DOOR, boolean_map={ BSH_DOOR_STATE_CLOSED: False, @@ -186,8 +173,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): }, ), ) - self._attr_unique_id = f"{device.appliance.haId}-Door" - self._attr_name = f"{device.appliance.name} Door" + self._attr_unique_id = f"{appliance.info.ha_id}-Door" + self._attr_name = f"{appliance.info.name} Door" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -234,6 +221,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() async_delete_issue( self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e20cf3b1fa0..127aa1ffe92 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,9 +1,9 @@ """Constants for the Home Connect integration.""" +from aiohomeconnect.model import EventKey, SettingKey, StatusKey + DOMAIN = "home_connect" -OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" -OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", @@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) -BSH_POWER_STATE = "BSH.Common.Setting.PowerState" + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" -BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram" -BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" -BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" -BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock" -BSH_REMAINING_PROGRAM_TIME = "BSH.Common.Option.RemainingProgramTime" -BSH_COMMON_OPTION_DURATION = "BSH.Common.Option.Duration" -BSH_COMMON_OPTION_PROGRAM_PROGRESS = "BSH.Common.Option.ProgramProgress" BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present" BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed" BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" + BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" -COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" -COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" - -COFFEE_EVENT_BEAN_CONTAINER_EMPTY = ( - "ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty" -) -COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty" -COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull" - -DISHWASHER_EVENT_SALT_NEARLY_EMPTY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty" -DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY = ( - "Dishcare.Dishwasher.Event.RinseAidNearlyEmpty" -) - -REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power" -REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.Internal.Brightness" -) -REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power" -REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.External.Brightness" -) - -REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" -REFRIGERATION_SUPERMODEREFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" -) -REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" - -REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon" -REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer" -REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator" REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed" REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open" -REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator" -) -REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer" -) -REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer" -) - -BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" -BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" -BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = ( "BSH.Common.EnumType.AmbientLightColor.CustomColor" ) -BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor" -BSH_DOOR_STATE = "BSH.Common.Status.DoorState" + BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" -BSH_PAUSE = "BSH.Common.Command.PauseProgram" -BSH_RESUME = "BSH.Common.Command.ResumeProgram" - -SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" SERVICE_OPTION_ACTIVE = "set_option_active" SERVICE_OPTION_SELECTED = "set_option_selected" @@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" -ATTR_ALLOWED_VALUES = "allowedvalues" -ATTR_AMBIENT = "ambient" -ATTR_BSH_KEY = "bsh_key" -ATTR_CONSTRAINTS = "constraints" -ATTR_DESC = "desc" -ATTR_DEVICE = "device" + ATTR_KEY = "key" ATTR_PROGRAM = "program" -ATTR_SENSOR_TYPE = "sensor_type" -ATTR_SIGN = "sign" -ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" -SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { - "ChildLock": BSH_CHILD_LOCK_STATE, - "Operation State": BSH_OPERATION_STATE, - "Light": COOKING_LIGHTING, - "AmbientLight": BSH_AMBIENT_LIGHT_ENABLED, - "Power": BSH_POWER_STATE, - "Remaining Program Time": BSH_REMAINING_PROGRAM_TIME, - "Duration": BSH_COMMON_OPTION_DURATION, - "Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS, - "Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE, - "Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE, - "Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER, - "Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR, - "Dispenser Enabled": REFRIGERATION_DISPENSER, - "Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER, - "External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER, - "Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER, - "Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER, - "Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR, - "Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - "Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - "Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, - "Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - "Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY, - "Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL, + "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, + "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, + "Light": SettingKey.COOKING_COMMON_LIGHTING, + "AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + "Power": SettingKey.BSH_COMMON_POWER_STATE, + "Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, + "Duration": EventKey.BSH_COMMON_OPTION_DURATION, + "Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, + "Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, + "Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, + "Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, + "Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, + "Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, + "Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + "External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + "Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + "Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, + "Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, + "Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + "Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + "Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + "Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py new file mode 100644 index 00000000000..2c70d74150e --- /dev/null +++ b/homeassistant/components/home_connect/coordinator.py @@ -0,0 +1,258 @@ +"""Coordinator for Home Connect.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass, field +import logging +from typing import Any + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + HomeAppliance, + SettingKey, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +from aiohomeconnect.model.program import EnumerateAvailableProgram +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .utils import get_dict_from_home_connect_error + +_LOGGER = logging.getLogger(__name__) + +type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] + +EVENT_STREAM_RECONNECT_DELAY = 30 + + +@dataclass(frozen=True, kw_only=True) +class HomeConnectApplianceData: + """Class to hold Home Connect appliance data.""" + + events: dict[EventKey, Event] = field(default_factory=dict) + info: HomeAppliance + programs: list[EnumerateAvailableProgram] = field(default_factory=list) + settings: dict[SettingKey, GetSetting] + status: dict[StatusKey, Status] + + def update(self, other: "HomeConnectApplianceData") -> None: + """Update data with data from other instance.""" + self.events.update(other.events) + self.info.connected = other.info.connected + self.programs.clear() + self.programs.extend(other.programs) + self.settings.update(other.settings) + self.status.update(other.status) + + +class HomeConnectCoordinator( + DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] +): + """Class to manage fetching Home Connect data.""" + + config_entry: HomeConnectConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HomeConnectConfigEntry, + client: HomeConnectClient, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.entry_id, + ) + self.client = client + + @cached_property + def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: + """Return a dict of all listeners registered for a given context.""" + listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list) + for listener, context in list(self._listeners.values()): + assert isinstance(context, tuple) + listeners[context].append(listener) + return listeners + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + remove_listener = super().async_add_listener(update_callback, context) + self.__dict__.pop("context_listeners", None) + + def remove_listener_and_invalidate_context_listeners() -> None: + remove_listener() + self.__dict__.pop("context_listeners", None) + + return remove_listener_and_invalidate_context_listeners + + @callback + def start_event_listener(self) -> None: + """Start event listener.""" + self.config_entry.async_create_background_task( + self.hass, + self._event_listener(), + f"home_connect-events_listener_task-{self.config_entry.entry_id}", + ) + + async def _event_listener(self) -> None: + """Match event with listener for event type.""" + while True: + try: + async for event_message in self.client.stream_all_events(): + match event_message.type: + case EventType.STATUS: + statuses = self.data[event_message.ha_id].status + for event in event_message.data.items: + status_key = StatusKey(event.key) + if status_key in statuses: + statuses[status_key].value = event.value + else: + statuses[status_key] = Status( + key=status_key, + raw_key=status_key.value, + value=event.value, + ) + + case EventType.NOTIFY: + settings = self.data[event_message.ha_id].settings + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + if event.key in SettingKey: + setting_key = SettingKey(event.key) + if setting_key in settings: + settings[setting_key].value = event.value + else: + settings[setting_key] = GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=event.value, + ) + else: + events[event.key] = event + + case EventType.EVENT: + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + events[event.key] = event + + self._call_event_listener(event_message) + + except (EventStreamInterruptedError, HomeConnectRequestError) as error: + _LOGGER.debug( + "Non-breaking error (%s) while listening for events," + " continuing in 30 seconds", + type(error).__name__, + ) + await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + except HomeConnectApiError as error: + _LOGGER.error("Error while listening for events: %s", error) + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + break + # if there was a non-breaking error, we continue listening + # but we need to refresh the data to get the possible changes + # that happened while the event stream was interrupted + await self.async_refresh() + + @callback + def _call_event_listener(self, event_message: EventMessage): + """Call listener for event.""" + for event in event_message.data.items: + for listener in self.context_listeners.get( + (event_message.ha_id, event.key), [] + ): + listener() + + async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: + """Fetch data from Home Connect.""" + try: + appliances = await self.client.get_home_appliances() + except HomeConnectError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_api_error", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error + + appliances_data = self.data or {} + for appliance in appliances.homeappliances: + try: + settings = { + setting.key: setting + for setting in ( + await self.client.get_settings(appliance.ha_id) + ).settings + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching settings for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + settings = {} + try: + status = { + status.key: status + for status in (await self.client.get_status(appliance.ha_id)).status + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching status for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + status = {} + appliance_data = HomeConnectApplianceData( + info=appliance, settings=settings, status=status + ) + if appliance.ha_id in appliances_data: + appliances_data[appliance.ha_id].update(appliance_data) + appliance_data = appliances_data[appliance.ha_id] + else: + appliances_data[appliance.ha_id] = appliance_data + if ( + appliance.type in APPLIANCES_WITH_PROGRAMS + and not appliance_data.programs + ): + try: + appliance_data.programs.extend( + ( + await self.client.get_available_programs(appliance.ha_id) + ).programs + ) + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching programs for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return appliances_data diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index e095bc503ab..fd74277a815 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,33 +4,25 @@ from __future__ import annotations from typing import Any -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import HomeConnectConfigEntry, _get_appliance -from .api import HomeConnectDevice +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: - try: - programs = appliance.get_programs_available() - except HomeConnectError: - programs = None +async def _generate_appliance_diagnostics( + client: HomeConnectClient, appliance: HomeConnectApplianceData +) -> dict[str, Any]: return { - "connected": appliance.connected, - "status": appliance.status, - "programs": programs, - } - - -def _generate_entry_diagnostics( - devices: list[HomeConnectDevice], -) -> dict[str, dict[str, Any]]: - return { - device.appliance.haId: _generate_appliance_diagnostics(device.appliance) - for device in devices + **appliance.info.to_dict(), + "status": {key.value: status.value for key, status in appliance.status.items()}, + "settings": { + key.value: setting.value for key, setting in appliance.settings.items() + }, + "programs": [program.raw_key for program in appliance.programs], } @@ -38,14 +30,21 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return await hass.async_add_executor_job( - _generate_entry_diagnostics, entry.runtime_data.devices - ) + return { + appliance.info.ha_id: await _generate_appliance_diagnostics( + entry.runtime_data.client, appliance + ) + for appliance in entry.runtime_data.data.values() + } async def async_get_device_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - appliance = _get_appliance(hass, device_entry=device, entry=entry) - return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) + ha_id = next( + (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), + ) + return await _generate_appliance_diagnostics( + entry.runtime_data.client, entry.runtime_data.data[ha_id] + ) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 0ae4a28b8d4..ba8500fe8b6 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,55 +1,56 @@ """Home Connect entity base class.""" +from abc import abstractmethod import logging +from aiohomeconnect.model import EventKey + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import HomeConnectDevice -from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator _LOGGER = logging.getLogger(__name__) -class HomeConnectEntity(Entity): +class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): """Generic Home Connect entity (base class).""" _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device: HomeConnectDevice, desc: EntityDescription) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: EntityDescription, + ) -> None: """Initialize the entity.""" - self.device = device + super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + self.appliance = appliance self.entity_description = desc - self._attr_unique_id = f"{device.appliance.haId}-{self.bsh_key}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.appliance.haId)}, - manufacturer=device.appliance.brand, - model=device.appliance.vib, - name=device.appliance.name, + identifiers={(DOMAIN, appliance.info.ha_id)}, + manufacturer=appliance.info.brand, + model=appliance.info.vib, + name=appliance.info.name, ) + self.update_native_value() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback - ) - ) + @abstractmethod + def update_native_value(self) -> None: + """Set the value of the entity.""" @callback - def _update_callback(self, ha_id: str) -> None: - """Update data.""" - if ha_id == self.device.appliance.haId: - self.async_entity_update() - - @callback - def async_entity_update(self) -> None: - """Update the entity.""" - _LOGGER.debug("Entity update triggered on %s", self) - self.async_schedule_update_ha_state(True) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_native_value() + self.async_write_ha_state() + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) @property def bsh_key(self) -> str: diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 3e81bcbddad..9d1c4d7a55b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -2,10 +2,10 @@ from dataclasses import dataclass import logging -from math import ceil -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,25 +20,18 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, DOMAIN, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, - REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_INTERNAL_LIGHT_POWER, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, ) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -47,38 +40,38 @@ _LOGGER = logging.getLogger(__name__) class HomeConnectLightEntityDescription(LightEntityDescription): """Light entity description.""" - brightness_key: str | None = None - color_key: str | None = None + brightness_key: SettingKey | None = None + color_key: SettingKey | None = None enable_custom_color_value_key: str | None = None - custom_color_key: str | None = None + custom_color_key: SettingKey | None = None brightness_scale: tuple[float, float] = (0.0, 100.0) LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( HomeConnectLightEntityDescription( - key=REFRIGERATION_INTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="internal_light", ), HomeConnectLightEntityDescription( - key=REFRIGERATION_EXTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="external_light", ), HomeConnectLightEntityDescription( - key=COOKING_LIGHTING, - brightness_key=COOKING_LIGHTING_BRIGHTNESS, + key=SettingKey.COOKING_COMMON_LIGHTING, + brightness_key=SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS, brightness_scale=(10.0, 100.0), translation_key="cooking_lighting", ), HomeConnectLightEntityDescription( - key=BSH_AMBIENT_LIGHT_ENABLED, - brightness_key=BSH_AMBIENT_LIGHT_BRIGHTNESS, - color_key=BSH_AMBIENT_LIGHT_COLOR, + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + brightness_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS, + color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, enable_custom_color_value_key=BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - custom_color_key=BSH_AMBIENT_LIGHT_CUSTOM_COLOR, + custom_color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR, brightness_scale=(10.0, 100.0), translation_key="ambient_light", ), @@ -92,16 +85,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect light.""" - def get_entities() -> list[LightEntity]: - """Get a list of entities.""" - return [ - HomeConnectLight(device, description) + async_add_entities( + [ + HomeConnectLight(entry.runtime_data, appliance, description) for description in LIGHTS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectLight(HomeConnectEntity, LightEntity): @@ -110,13 +101,17 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): entity_description: LightEntityDescription def __init__( - self, device: HomeConnectDevice, desc: HomeConnectLightEntityDescription + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectLightEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__(device, desc) - def get_setting_key_if_setting_exists(setting_key: str | None) -> str | None: - if setting_key and setting_key in device.appliance.status: + def get_setting_key_if_setting_exists( + setting_key: SettingKey | None, + ) -> SettingKey | None: + if setting_key and setting_key in appliance.settings: return setting_key return None @@ -131,6 +126,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) self._brightness_scale = desc.brightness_scale + super().__init__(coordinator, appliance, desc) + match (self._brightness_key, self._custom_color_key): case (None, None): self._attr_color_mode = ColorMode.ONOFF @@ -144,10 +141,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" - _LOGGER.debug("Switching light on for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: raise HomeAssistantError( @@ -158,15 +156,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - if self._custom_color_key: + if self._color_key and self._custom_color_key: if ( ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs ) and self._enable_custom_color_value_key: try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._color_key, - self._enable_custom_color_value_key, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._color_key, + value=self._enable_custom_color_value_key, ) except HomeConnectError as err: raise HomeAssistantError( @@ -181,10 +179,10 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): if ATTR_RGB_COLOR in kwargs: hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) except HomeConnectError as err: raise HomeAssistantError( @@ -195,10 +193,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - elif (ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs) and ( - self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs + return + if (self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs) and ( + self._attr_hs_color is not None or ATTR_HS_COLOR in kwargs ): - brightness = 10 + ceil( + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), @@ -207,41 +206,36 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - if hs_color is not None: - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness + rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) + hex_val = color_util.color_rgb_to_hex(*rgb) + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) - hex_val = color_util.color_rgb_to_hex(*rgb) - try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_light_color", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - }, - ) from err + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_light_color", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + }, + ) from err + return - elif self._brightness_key and ATTR_BRIGHTNESS in kwargs: - _LOGGER.debug( - "Changing brightness for: %s, to: %s", - self.name, - kwargs[ATTR_BRIGHTNESS], - ) - brightness = ceil( + if self._brightness_key and ATTR_BRIGHTNESS in kwargs: + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs[ATTR_BRIGHTNESS] ) ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self._brightness_key, brightness + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._brightness_key, + value=brightness, ) except HomeConnectError as err: raise HomeAssistantError( @@ -253,14 +247,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): }, ) from err - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Switch the light off.""" - _LOGGER.debug("Switching light off for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: raise HomeAssistantError( @@ -271,30 +264,50 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + keys_to_listen = [] + if self._brightness_key: + keys_to_listen.append(self._brightness_key) + if self._color_key and self._custom_color_key: + keys_to_listen.extend([self._color_key, self._custom_color_key]) + for key in keys_to_listen: + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, + ( + self.appliance.info.ha_id, + EventKey(key), + ), + ) + ) + + def update_native_value(self) -> None: """Update the light's status.""" - if self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is True: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is False - ): - self._attr_is_on = False - else: - self._attr_is_on = None + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value - _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) - - if self._custom_color_key: - color = self.device.appliance.status.get(self._custom_color_key, {}) - - if not color: + if self._brightness_key: + brightness = cast( + float, self.appliance.settings[self._brightness_key].value + ) + self._attr_brightness = color_util.value_to_brightness( + self._brightness_scale, brightness + ) + _LOGGER.debug( + "Updated %s, new brightness: %s", self.entity_id, self._attr_brightness + ) + if self._color_key and self._custom_color_key: + color = cast(str, self.appliance.settings[self._color_key].value) + if color != self._enable_custom_color_value_key: self._attr_rgb_color = None self._attr_hs_color = None - self._attr_brightness = None else: - color_value = color.get(ATTR_VALUE)[1:] + custom_color = cast( + str, self.appliance.settings[self._custom_color_key].value + ) + color_value = custom_color[1:] rgb = color_util.rgb_hex_to_rgb_list(color_value) self._attr_rgb_color = (rgb[0], rgb[1], rgb[2]) hsv = color_util.color_RGB_to_hsv(*rgb) @@ -303,16 +316,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._brightness_scale, hsv[2] ) _LOGGER.debug( - "Updated, new color (%s) and new brightness (%s) ", + "Updated %s, new color (%s) and new brightness (%s) ", + self.entity_id, color_value, self._attr_brightness, ) - elif self._brightness_key: - brightness = self.device.appliance.status.get(self._brightness_key, {}) - if brightness is None: - self._attr_brightness = None - else: - self._attr_brightness = color_util.value_to_brightness( - self._brightness_scale, brightness[ATTR_VALUE] - ) - _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index e041e13d36b..905a7c67f11 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["homeconnect"], - "requirements": ["homeconnect==0.8.0"] + "requirements": ["aiohomeconnect==0.12.1"] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 0703b4772bb..7c6101950bf 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,12 +1,12 @@ """Provides number enties for Home Connect.""" import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,66 +15,63 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) NUMBERS = ( NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, translation_key="refrigerator_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_FREEZER, device_class=NumberDeviceClass.TEMPERATURE, translation_key="freezer_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.BottleCooler.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_BOTTLE_COOLER_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="bottle_cooler_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerLeft.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_LEFT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_left_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_COMMON_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerRight.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_RIGHT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_right_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment2.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_2_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_2_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment3.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_3_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), @@ -87,17 +84,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect number.""" - - def get_entities() -> list[HomeConnectNumberEntity]: - """Get a list of entities.""" - return [ - HomeConnectNumberEntity(device, description) + async_add_entities( + [ + HomeConnectNumberEntity(entry.runtime_data, appliance, description) for description in NUMBERS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): @@ -112,10 +106,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): self.entity_id, ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - value, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=value, ) except HomeConnectError as err: raise HomeAssistantError( @@ -132,34 +126,41 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) ) except HomeConnectError as err: _LOGGER.error("An error occurred: %s", err) - return - if not data or not (constraints := data.get(ATTR_CONSTRAINTS)): - return - self._attr_native_max_value = constraints.get(ATTR_MAX) - self._attr_native_min_value = constraints.get(ATTR_MIN) - self._attr_native_step = constraints.get(ATTR_STEPSIZE) - self._attr_native_unit_of_measurement = data.get(ATTR_UNIT) + else: + self.set_constraints(data) - async def async_update(self) -> None: - """Update the number setting status.""" - if not (data := self.device.appliance.status.get(self.bsh_key)): - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None + def set_constraints(self, setting: GetSetting) -> None: + """Set constraints for the number entity.""" + if not (constraints := setting.constraints): return - self._attr_native_value = data.get(ATTR_VALUE, None) - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + if constraints.max: + self._attr_native_max_value = constraints.max + if constraints.min: + self._attr_native_min_value = constraints.min + if constraints.step_size: + self._attr_native_step = constraints.step_size + else: + self._attr_native_step = 0.1 if setting.type == "Double" else 1 + def update_native_value(self) -> None: + """Update status when an event for the entity is received.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = cast(float, data.value) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_unit_of_measurement = data.unit + self.set_constraints(data) if ( not hasattr(self, "_attr_native_min_value") - or self._attr_native_min_value is None or not hasattr(self, "_attr_native_max_value") - or self._attr_native_max_value is None or not hasattr(self, "_attr_native_step") - or self._attr_native_step is None ): await self.async_fetch_constraints() diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index a4a5861afbe..c7408094aed 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,191 +1,28 @@ """Provides a select platform for Home Connect.""" -import contextlib -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .coordinator import ( + HomeConnectApplianceData, HomeConnectConfigEntry, - bsh_key_to_translation_key, - get_dict_from_home_connect_error, -) -from .api import HomeConnectDevice -from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program): program - for program in ( - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap", - "ConsumerProducts.CleaningRobot.Program.Basic.GoHome", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino", - "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte", - "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth", - "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye", - "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater", - "Dishcare.Dishwasher.Program.PreRinse", - "Dishcare.Dishwasher.Program.Auto1", - "Dishcare.Dishwasher.Program.Auto2", - "Dishcare.Dishwasher.Program.Auto3", - "Dishcare.Dishwasher.Program.Eco50", - "Dishcare.Dishwasher.Program.Quick45", - "Dishcare.Dishwasher.Program.Intensiv70", - "Dishcare.Dishwasher.Program.Normal65", - "Dishcare.Dishwasher.Program.Glas40", - "Dishcare.Dishwasher.Program.GlassCare", - "Dishcare.Dishwasher.Program.NightWash", - "Dishcare.Dishwasher.Program.Quick65", - "Dishcare.Dishwasher.Program.Normal45", - "Dishcare.Dishwasher.Program.Intensiv45", - "Dishcare.Dishwasher.Program.AutoHalfLoad", - "Dishcare.Dishwasher.Program.IntensivPower", - "Dishcare.Dishwasher.Program.MagicDaily", - "Dishcare.Dishwasher.Program.Super60", - "Dishcare.Dishwasher.Program.Kurz60", - "Dishcare.Dishwasher.Program.ExpressSparkle65", - "Dishcare.Dishwasher.Program.MachineCare", - "Dishcare.Dishwasher.Program.SteamFresh", - "Dishcare.Dishwasher.Program.MaximumCleaning", - "Dishcare.Dishwasher.Program.MixedLoad", - "LaundryCare.Dryer.Program.Cotton", - "LaundryCare.Dryer.Program.Synthetic", - "LaundryCare.Dryer.Program.Mix", - "LaundryCare.Dryer.Program.Blankets", - "LaundryCare.Dryer.Program.BusinessShirts", - "LaundryCare.Dryer.Program.DownFeathers", - "LaundryCare.Dryer.Program.Hygiene", - "LaundryCare.Dryer.Program.Jeans", - "LaundryCare.Dryer.Program.Outdoor", - "LaundryCare.Dryer.Program.SyntheticRefresh", - "LaundryCare.Dryer.Program.Towels", - "LaundryCare.Dryer.Program.Delicates", - "LaundryCare.Dryer.Program.Super40", - "LaundryCare.Dryer.Program.Shirts15", - "LaundryCare.Dryer.Program.Pillow", - "LaundryCare.Dryer.Program.AntiShrink", - "LaundryCare.Dryer.Program.MyTime.MyDryingTime", - "LaundryCare.Dryer.Program.TimeCold", - "LaundryCare.Dryer.Program.TimeWarm", - "LaundryCare.Dryer.Program.InBasket", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60", - "LaundryCare.Dryer.Program.Dessous", - "Cooking.Common.Program.Hood.Automatic", - "Cooking.Common.Program.Hood.Venting", - "Cooking.Common.Program.Hood.DelayedShutOff", - "Cooking.Oven.Program.HeatingMode.PreHeating", - "Cooking.Oven.Program.HeatingMode.HotAir", - "Cooking.Oven.Program.HeatingMode.HotAirEco", - "Cooking.Oven.Program.HeatingMode.HotAirGrilling", - "Cooking.Oven.Program.HeatingMode.TopBottomHeating", - "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", - "Cooking.Oven.Program.HeatingMode.BottomHeating", - "Cooking.Oven.Program.HeatingMode.PizzaSetting", - "Cooking.Oven.Program.HeatingMode.SlowCook", - "Cooking.Oven.Program.HeatingMode.IntensiveHeat", - "Cooking.Oven.Program.HeatingMode.KeepWarm", - "Cooking.Oven.Program.HeatingMode.PreheatOvenware", - "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", - "Cooking.Oven.Program.HeatingMode.Desiccation", - "Cooking.Oven.Program.HeatingMode.Defrost", - "Cooking.Oven.Program.HeatingMode.Proof", - "Cooking.Oven.Program.HeatingMode.HotAir30Steam", - "Cooking.Oven.Program.HeatingMode.HotAir60Steam", - "Cooking.Oven.Program.HeatingMode.HotAir80Steam", - "Cooking.Oven.Program.HeatingMode.HotAir100Steam", - "Cooking.Oven.Program.HeatingMode.SabbathProgramme", - "Cooking.Oven.Program.Microwave.90Watt", - "Cooking.Oven.Program.Microwave.180Watt", - "Cooking.Oven.Program.Microwave.360Watt", - "Cooking.Oven.Program.Microwave.600Watt", - "Cooking.Oven.Program.Microwave.900Watt", - "Cooking.Oven.Program.Microwave.1000Watt", - "Cooking.Oven.Program.Microwave.Max", - "Cooking.Oven.Program.HeatingMode.WarmingDrawer", - "LaundryCare.Washer.Program.Cotton", - "LaundryCare.Washer.Program.Cotton.CottonEco", - "LaundryCare.Washer.Program.Cotton.Eco4060", - "LaundryCare.Washer.Program.Cotton.Colour", - "LaundryCare.Washer.Program.EasyCare", - "LaundryCare.Washer.Program.Mix", - "LaundryCare.Washer.Program.Mix.NightWash", - "LaundryCare.Washer.Program.DelicatesSilk", - "LaundryCare.Washer.Program.Wool", - "LaundryCare.Washer.Program.Sensitive", - "LaundryCare.Washer.Program.Auto30", - "LaundryCare.Washer.Program.Auto40", - "LaundryCare.Washer.Program.Auto60", - "LaundryCare.Washer.Program.Chiffon", - "LaundryCare.Washer.Program.Curtains", - "LaundryCare.Washer.Program.DarkWash", - "LaundryCare.Washer.Program.Dessous", - "LaundryCare.Washer.Program.Monsoon", - "LaundryCare.Washer.Program.Outdoor", - "LaundryCare.Washer.Program.PlushToy", - "LaundryCare.Washer.Program.ShirtsBlouses", - "LaundryCare.Washer.Program.SportFitness", - "LaundryCare.Washer.Program.Towels", - "LaundryCare.Washer.Program.WaterProof", - "LaundryCare.Washer.Program.PowerSpeed59", - "LaundryCare.Washer.Program.Super153045.Super15", - "LaundryCare.Washer.Program.Super153045.Super1530", - "LaundryCare.Washer.Program.DownDuvet.Duvet", - "LaundryCare.Washer.Program.Rinse.RinseSpinDrain", - "LaundryCare.Washer.Program.DrumClean", - "LaundryCare.WasherDryer.Program.Cotton", - "LaundryCare.WasherDryer.Program.Cotton.Eco4060", - "LaundryCare.WasherDryer.Program.Mix", - "LaundryCare.WasherDryer.Program.EasyCare", - "LaundryCare.WasherDryer.Program.WashAndDry60", - "LaundryCare.WasherDryer.Program.WashAndDry90", - ) + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN } PROGRAMS_TRANSLATION_KEYS_MAP = { @@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( SelectEntityDescription( - key=BSH_ACTIVE_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, translation_key="active_program", ), SelectEntityDescription( - key=BSH_SELECTED_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, translation_key="selected_program", ), ) @@ -211,31 +48,12 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect select entities.""" - def get_entities() -> list[HomeConnectProgramSelectEntity]: - """Get a list of entities.""" - entities: list[HomeConnectProgramSelectEntity] = [] - programs_not_found = set() - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - for program in programs.copy(): - if program not in PROGRAMS_TRANSLATION_KEYS_MAP: - programs.remove(program) - if program not in programs_not_found: - _LOGGER.info( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - programs_not_found.add(program) - entities.extend( - HomeConnectProgramSelectEntity(device, programs, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities( + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for appliance in entry.runtime_data.data.values() + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + ) class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): @@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): def __init__( self, - device: HomeConnectDevice, - programs: list[str], + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, desc: SelectEntityDescription, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, desc, ) self._attr_options = [ - PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs + PROGRAMS_TRANSLATION_KEYS_MAP[program.key] + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN ] - self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM + self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + self._attr_current_option = None - async def async_update(self) -> None: - """Update the program selection status.""" - program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - if not program: - program_translation_key = None - elif not ( - program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) - ): - _LOGGER.debug( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - self._attr_current_option = program_translation_key - _LOGGER.debug("Updated, new program: %s", self._attr_current_option) + def update_native_value(self) -> None: + """Set the program value.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + self._attr_current_option = ( + PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value)) + if event + else None + ) async def async_select_option(self, option: str) -> None: """Select new program.""" - bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] - _LOGGER.debug( - "Starting program: %s" if self.start_on_select else "Selecting program: %s", - bsh_key, - ) - if self.start_on_select: - target = self.device.appliance.start_program - else: - target = self.device.appliance.select_program + program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] try: - await self.hass.async_add_executor_job(target, bsh_key) + if self.start_on_select: + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=program_key + ) + else: + await self.coordinator.client.set_selected_program( + self.appliance.info.ha_id, program_key=program_key + ) except HomeConnectError as err: if self.start_on_select: translation_key = "start_program" @@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err - self.async_entity_update() diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c11254d2c02..5e7c417a172 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,11 @@ """Provides a sensor for Home Connect.""" from dataclasses import dataclass -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import cast +from aiohomeconnect.model import EventKey, StatusKey + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -12,38 +13,26 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from . import HomeConnectConfigEntry from .const import ( APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_DOOR_STATE, - BSH_OPERATION_STATE, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - COFFEE_EVENT_DRIP_TRAY_FULL, - COFFEE_EVENT_WATER_TANK_EMPTY, - DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, - DISHWASHER_EVENT_SALT_NEARLY_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) - - EVENT_OPTIONS = ["confirmed", "off", "present"] @dataclass(frozen=True, kw_only=True) -class HomeConnectSensorEntityDescription(SensorEntityDescription): +class HomeConnectSensorEntityDescription( + SensorEntityDescription, +): """Entity Description class for sensors.""" default_value: str | None = None @@ -52,7 +41,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( - key="BSH.Common.Option.RemainingProgramTime", + key=EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( @@ -67,13 +56,13 @@ BSH_PROGRAM_SENSORS = ( ), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.Duration", + key=EventKey.BSH_COMMON_OPTION_DURATION, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.ProgramProgress", + key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, translation_key="program_progress", appliance_types=APPLIANCES_WITH_PROGRAMS, @@ -82,7 +71,7 @@ BSH_PROGRAM_SENSORS = ( SENSORS = ( HomeConnectSensorEntityDescription( - key=BSH_OPERATION_STATE, + key=StatusKey.BSH_COMMON_OPERATION_STATE, device_class=SensorDeviceClass.ENUM, options=[ "inactive", @@ -98,7 +87,7 @@ SENSORS = ( translation_key="operation_state", ), HomeConnectSensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=SensorDeviceClass.ENUM, options=[ "closed", @@ -108,59 +97,59 @@ SENSORS = ( translation_key="door", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="powder_coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER, native_unit_of_measurement=UnitOfVolume.MILLILITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_cups_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="frothy_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_and_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="ristretto_espresso_counter", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.BatteryLevel", + key=StatusKey.BSH_COMMON_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, translation_key="battery_level", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.Video.CameraState", + key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE, device_class=SensorDeviceClass.ENUM, options=[ "disabled", @@ -174,7 +163,7 @@ SENSORS = ( translation_key="camera_state", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LAST_SELECTED_MAP, device_class=SensorDeviceClass.ENUM, options=[ "tempmap", @@ -188,7 +177,7 @@ SENSORS = ( EVENT_SENSORS = ( HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -196,7 +185,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -204,7 +193,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Refrigerator"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -212,7 +201,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -220,7 +209,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_WATER_TANK_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -228,7 +217,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_DRIP_TRAY_FULL, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -236,7 +225,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -244,7 +233,7 @@ EVENT_SENSORS = ( appliance_types=("Dishwasher",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -261,33 +250,30 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect sensor.""" - def get_entities() -> list[SensorEntity]: - """Get a list of entities.""" - entities: list[SensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectSensor( - device, - description, - ) - for description in EVENT_SENSORS - if description.appliance_types - and device.appliance.type in description.appliance_types + entities: list[SensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectEventSensor( + entry.runtime_data, + appliance, + description, ) - entities.extend( - HomeConnectProgramSensor(device, desc) - for desc in BSH_PROGRAM_SENSORS - if desc.appliance_types - and device.appliance.type in desc.appliance_types - ) - entities.extend( - HomeConnectSensor(device, description) - for description in SENSORS - if description.key in device.appliance.status - ) - return entities + for description in EVENT_SENSORS + if description.appliance_types + and appliance.info.type in description.appliance_types + ) + entities.extend( + HomeConnectProgramSensor(entry.runtime_data, appliance, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types and appliance.info.type in desc.appliance_types + ) + entities.extend( + HomeConnectSensor(entry.runtime_data, appliance, description) + for description in SENSORS + if description.key in appliance.status + ) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSensor(HomeConnectEntity, SensorEntity): @@ -295,44 +281,25 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - async def async_update(self) -> None: - """Update the sensor's status.""" - appliance_status = self.device.appliance.status - if ( - self.bsh_key not in appliance_status - or ATTR_VALUE not in appliance_status[self.bsh_key] - ): - self._attr_native_value = self.entity_description.default_value - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) - return - status = appliance_status[self.bsh_key] + def update_native_value(self) -> None: + """Set the value of the sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + self._update_native_value(status) + + def _update_native_value(self, status: str | float) -> None: + """Set the value of the sensor based on the given value.""" match self.device_class: case SensorDeviceClass.TIMESTAMP: - if ATTR_VALUE not in status: - self._attr_native_value = None - elif ( - self._attr_native_value is not None - and isinstance(self._attr_native_value, datetime) - and self._attr_native_value < dt_util.utcnow() - ): - # if the date is supposed to be in the future but we're - # already past it, set state to None. - self._attr_native_value = None - else: - seconds = float(status[ATTR_VALUE]) - self._attr_native_value = dt_util.utcnow() + timedelta( - seconds=seconds - ) + self._attr_native_value = dt_util.utcnow() + timedelta( + seconds=cast(float, status) + ) case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._attr_native_value = slugify( - cast(str, status.get(ATTR_VALUE)).split(".")[-1] - ) + self._attr_native_value = slugify(cast(str, status).split(".")[-1]) case _: - self._attr_native_value = status.get(ATTR_VALUE) - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + self._attr_native_value = status class HomeConnectProgramSensor(HomeConnectSensor): @@ -340,6 +307,31 @@ class HomeConnectProgramSensor(HomeConnectSensor): program_running: bool = False + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_operation_state_event, + (self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE), + ) + ) + + @callback + def _handle_operation_state_event(self) -> None: + """Update status when an event for the entity is received.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + if not self.program_running: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None + self.async_write_ha_state() + @property def available(self) -> bool: """Return true if the sensor is available.""" @@ -347,20 +339,20 @@ class HomeConnectProgramSensor(HomeConnectSensor): # Otherwise, some sensors report erroneous values. return super().available and self.program_running - async def async_update(self) -> None: + def update_native_value(self) -> None: + """Update the program sensor's status.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + + +class HomeConnectEventSensor(HomeConnectSensor): + """Sensor class for Home Connect events.""" + + def update_native_value(self) -> None: """Update the sensor's status.""" - self.program_running = ( - BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ) - if self.program_running: - await super().async_update() - else: - # reset the value when the program is not running, paused or finished - self._attr_native_value = None + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + elif not self._attr_native_value: + self._attr_native_value = self.entity_description.default_value diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7ededaae5b7..d163d04a6f7 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -26,64 +26,67 @@ "message": "Appliance for device ID {device_id} not found" }, "turn_on_light": { - "message": "Error turning on {entity_id}: {description}" + "message": "Error turning on {entity_id}: {error}" }, "turn_off_light": { - "message": "Error turning off {entity_id}: {description}" + "message": "Error turning off {entity_id}: {error}" }, "set_light_brightness": { - "message": "Error setting brightness of {entity_id}: {description}" + "message": "Error setting brightness of {entity_id}: {error}" }, "select_light_custom_color": { - "message": "Error selecting custom color of {entity_id}: {description}" + "message": "Error selecting custom color of {entity_id}: {error}" }, "set_light_color": { - "message": "Error setting color of {entity_id}: {description}" + "message": "Error setting color of {entity_id}: {error}" }, "set_setting_entity": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {error}" }, "set_setting": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {error}" }, "turn_on": { - "message": "Error turning on {entity_id} ({key}): {description}" + "message": "Error turning on {entity_id} ({key}): {error}" }, "turn_off": { - "message": "Error turning off {entity_id} ({key}): {description}" + "message": "Error turning off {entity_id} ({key}): {error}" }, "select_program": { - "message": "Error selecting program {program}: {description}" + "message": "Error selecting program {program}: {error}" }, "start_program": { - "message": "Error starting program {program}: {description}" + "message": "Error starting program {program}: {error}" }, "pause_program": { - "message": "Error pausing program: {description}" + "message": "Error pausing program: {error}" }, "stop_program": { - "message": "Error stopping program: {description}" + "message": "Error stopping program: {error}" }, "set_options_active_program": { - "message": "Error setting options for the active program: {description}" + "message": "Error setting options for the active program: {error}" }, "set_options_selected_program": { - "message": "Error setting options for the selected program: {description}" + "message": "Error setting options for the selected program: {error}" }, "execute_command": { - "message": "Error executing command {command}: {description}" + "message": "Error executing command {command}: {error}" }, "power_on": { - "message": "Error turning on {appliance_name}: {description}" + "message": "Error turning on {appliance_name}: {error}" }, "power_off": { - "message": "Error turning off {appliance_name} with value \"{value}\": {description}" + "message": "Error turning off {appliance_name} with value \"{value}\": {error}" }, "turn_off_not_supported": { "message": "{appliance_name} does not support turning off or entering standby mode." }, "unable_to_retrieve_turn_off": { "message": "Unable to turn off {appliance_name} because its support for turning off or entering standby mode could not be determined." + }, + "fetch_api_error": { + "message": "Error obtaining data from the API: {error}" } }, "issues": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 1bd02e03eb1..c3a0858e0bb 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -1,10 +1,11 @@ """Provides a switch for Home Connect.""" -import contextlib import logging -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -18,87 +19,83 @@ from homeassistant.helpers.issue_registry import ( async_create_issue, async_delete_issue, ) +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_DISPENSER, - REFRIGERATION_SUPERMODEFREEZER, - REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) -from .entity import HomeConnectDevice, HomeConnectEntity +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) SWITCHES = ( SwitchEntityDescription( - key=BSH_CHILD_LOCK_STATE, + key=SettingKey.BSH_COMMON_CHILD_LOCK, translation_key="child_lock", ), SwitchEntityDescription( - key="ConsumerProducts.CoffeeMaker.Setting.CupWarmer", + key=SettingKey.CONSUMER_PRODUCTS_COFFEE_MAKER_CUP_WARMER, translation_key="cup_warmer", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEFREEZER, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, translation_key="freezer_super_mode", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEREFRIGERATOR, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, translation_key="refrigerator_super_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.EcoMode", + key=SettingKey.REFRIGERATION_COMMON_ECO_MODE, translation_key="eco_mode", ), SwitchEntityDescription( - key="Cooking.Oven.Setting.SabbathMode", + key=SettingKey.COOKING_OVEN_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.SabbathMode", + key=SettingKey.REFRIGERATION_COMMON_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.VacationMode", + key=SettingKey.REFRIGERATION_COMMON_VACATION_MODE, translation_key="vacation_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.FreshMode", + key=SettingKey.REFRIGERATION_COMMON_FRESH_MODE, translation_key="fresh_mode", ), SwitchEntityDescription( - key=REFRIGERATION_DISPENSER, + key=SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, translation_key="dispenser_enabled", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFridge", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FRIDGE, translation_key="door_assistant_fridge", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFreezer", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FREEZER, translation_key="door_assistant_freezer", ), ) POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( - key=BSH_POWER_STATE, + key=SettingKey.BSH_COMMON_POWER_STATE, translation_key="power", ) @@ -110,29 +107,26 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[SwitchEntity]: - """Get a list of entities.""" - entities: list[SwitchEntity] = [] - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - entities.extend( - HomeConnectProgramSwitch(device, program) - for program in programs - ) - if BSH_POWER_STATE in device.appliance.status: - entities.append(HomeConnectPowerSwitch(device)) - entities.extend( - HomeConnectSwitch(device, description) - for description in SWITCHES - if description.key in device.appliance.status + entities: list[SwitchEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectProgramSwitch(entry.runtime_data, appliance, program) + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN + ) + if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: + entities.append( + HomeConnectPowerSwitch( + entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION + ) ) + entities.extend( + HomeConnectSwitch(entry.runtime_data, appliance, description) + for description in SWITCHES + if description.key in appliance.settings + ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): @@ -140,11 +134,11 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on setting.""" - - _LOGGER.debug("Turning on %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: self._attr_available = False @@ -158,19 +152,15 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Turn off setting.""" - - _LOGGER.debug("Turning off %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: - _LOGGER.error("Error while trying to turn off: %s", err) self._attr_available = False raise HomeAssistantError( translation_domain=DOMAIN, @@ -182,38 +172,35 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - - async def async_update(self) -> None: + def update_native_value(self) -> None: """Update the switch's status.""" - - self._attr_is_on = self.device.appliance.status.get( - self.entity_description.key, {} - ).get(ATTR_VALUE) - self._attr_available = True - _LOGGER.debug( - "Updated %s, new state: %s", - self.entity_description.key, - self._attr_is_on, - ) + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" - def __init__(self, device: HomeConnectDevice, program_name: str) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + program: EnumerateAvailableProgram, + ) -> None: """Initialize the entity.""" - desc = " ".join(["Program", program_name.split(".")[-1]]) - if device.appliance.type == "WasherDryer": + desc = " ".join(["Program", program.key.split(".")[-1]]) + if appliance.info.type == "WasherDryer": desc = " ".join( - ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] ) - super().__init__(device, SwitchEntityDescription(key=program_name)) - self._attr_name = f"{device.appliance.name} {desc}" - self._attr_unique_id = f"{device.appliance.haId}-{desc}" + super().__init__( + coordinator, + appliance, + SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), + ) + self._attr_name = f"{appliance.info.name} {desc}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" self._attr_has_entity_name = False - self.program_name = program_name + self.program = program async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -266,10 +253,9 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" - _LOGGER.debug("Tried to turn on program %s", self.program_name) try: - await self.hass.async_add_executor_job( - self.device.appliance.start_program, self.program_name + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=self.program.key ) except HomeConnectError as err: raise HomeAssistantError( @@ -277,16 +263,14 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): translation_key="start_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, + "program": self.program.key, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Stop the program.""" - _LOGGER.debug("Tried to stop program %s", self.program_name) try: - await self.hass.async_add_executor_job(self.device.appliance.stop_program) + await self.coordinator.client.stop_program(self.appliance.info.ha_id) except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -295,48 +279,25 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): **get_dict_from_home_connect_error(err), }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) - if state.get(ATTR_VALUE) == self.program_name: - self._attr_is_on = True - else: - self._attr_is_on = False - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + def update_native_value(self) -> None: + """Update the switch's status based on if the program related to this entity is currently active.""" + event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) + self._attr_is_on = bool(event and event.value == self.program.key) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" - power_off_state: str | None - - def __init__(self, device: HomeConnectDevice) -> None: - """Initialize the entity.""" - super().__init__( - device, - POWER_SWITCH_DESCRIPTION, - ) - if ( - power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( - ATTR_VALUE - ) - ) and power_state in [BSH_POWER_OFF, BSH_POWER_STANDBY]: - self.power_off_state = power_state - - async def async_added_to_hass(self) -> None: - """Add the entity to the hass instance.""" - await super().async_added_to_hass() - if not hasattr(self, "power_off_state"): - await self.async_fetch_power_off_state() + power_off_state: str | None | UndefinedType = UNDEFINED async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" - _LOGGER.debug("Tried to switch on %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=BSH_POWER_ON, ) except HomeConnectError as err: self._attr_is_on = False @@ -345,36 +306,36 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Switch the device off.""" - if not hasattr(self, "power_off_state"): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_retrieve_turn_off", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name - }, - ) + if self.power_off_state is UNDEFINED: + await self.async_fetch_power_off_state() + if self.power_off_state is UNDEFINED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_retrieve_turn_off", + translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + }, + ) if self.power_off_state is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name }, ) - _LOGGER.debug("tried to switch off %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - BSH_POWER_STATE, - self.power_off_state, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=self.power_off_state, ) except HomeConnectError as err: self._attr_is_on = True @@ -383,46 +344,51 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - if ( - self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == BSH_POWER_ON - ): + def update_native_value(self) -> None: + """Set the value of the entity.""" + power_state = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + value = cast(str, power_state.value) + if value == BSH_POWER_ON: self._attr_is_on = True elif ( - hasattr(self, "power_off_state") - and self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == self.power_off_state + isinstance(self.power_off_state, str) + and self.power_off_state + and value == self.power_off_state ): self._attr_is_on = False + elif self.power_off_state is UNDEFINED and value in [ + BSH_POWER_OFF, + BSH_POWER_STANDBY, + ]: + self.power_off_state = value + self._attr_is_on = False else: self._attr_is_on = None - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) async def async_fetch_power_off_state(self) -> None: """Fetch the power off state.""" - try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" - ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - return - if not data or not ( - allowed_values := data.get(ATTR_CONSTRAINTS, {}).get(ATTR_ALLOWED_VALUES) - ): + data = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + + if not data.constraints or not data.constraints.allowed_values: + try: + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + ) + except HomeConnectError as err: + _LOGGER.error("An error occurred fetching the power settings: %s", err) + return + if not data.constraints or not data.constraints.allowed_values: return - if BSH_POWER_OFF in allowed_values: + if BSH_POWER_OFF in data.constraints.allowed_values: self.power_off_state = BSH_POWER_OFF - elif BSH_POWER_STANDBY in allowed_values: + elif BSH_POWER_STANDBY in data.constraints.allowed_values: self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index c1f125cd2f7..5ed07424082 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,32 +1,30 @@ """Provides time enties for Home Connect.""" from datetime import time -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) - +from .utils import get_dict_from_home_connect_error TIME_ENTITIES = ( TimeEntityDescription( - key="BSH.Common.Setting.AlarmClock", + key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", ), ) @@ -39,16 +37,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[HomeConnectTimeEntity]: - """Get a list of entities.""" - return [ - HomeConnectTimeEntity(device, description) + async_add_entities( + [ + HomeConnectTimeEntity(entry.runtime_data, appliance, description) for description in TIME_ENTITIES - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) def seconds_to_time(seconds: int) -> time: @@ -68,17 +64,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" - _LOGGER.debug( - "Tried to set value %s to %s for %s", - value, - self.bsh_key, - self.entity_id, - ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - time_to_seconds(value), + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=time_to_seconds(value), ) except HomeConnectError as err: raise HomeAssistantError( @@ -92,16 +82,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): }, ) from err - async def async_update(self) -> None: - """Update the Time setting status.""" - data = self.device.appliance.status.get(self.bsh_key) - if data is None: - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None - return - seconds = data.get(ATTR_VALUE, None) - if seconds is not None: - self._attr_native_value = seconds_to_time(seconds) - else: - self._attr_native_value = None - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = seconds_to_time(data.value) diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py new file mode 100644 index 00000000000..108465072e1 --- /dev/null +++ b/homeassistant/components/home_connect/utils.py @@ -0,0 +1,29 @@ +"""Utility functions for Home Connect.""" + +import re + +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError + +RE_CAMEL_CASE = re.compile(r"(? dict[str, str]: + """Return a translation string from a Home Connect error.""" + return { + "error": str(err) + if isinstance(err, HomeConnectApiError) + else type(err).__name__ + } + + +def bsh_key_to_translation_key(bsh_key: str) -> str: + """Convert a BSH key to a translation key format. + + This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, + and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + """ + return "_".join( + RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") + ).lower() diff --git a/requirements_all.txt b/requirements_all.txt index 9e6da1045a4..731b1cdeb67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,6 +263,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -1148,9 +1151,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ae46099c2..db89f8db9d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -248,6 +248,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -977,9 +980,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 2ac8c851e1b..af039f04c03 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -1,18 +1,32 @@ """Test fixtures for home_connect.""" -from collections.abc import Awaitable, Callable, Generator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +import copy import time -from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + ArrayOfHomeAppliances, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Option, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect import update_all_devices from homeassistant.components.home_connect.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,12 +34,17 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture -MOCK_APPLIANCES_PROPERTIES = { - x["name"]: x - for x in load_json_object_fixture("home_connect/appliances.json")["data"][ - "homeappliances" - ] -} +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( + "home_connect/programs-available.json" +) +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -102,32 +121,23 @@ def platforms() -> list[Platform]: return [] -async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry): - """Add kwarg to disable throttle.""" - await update_all_devices(hass, config_entry, no_throttle=True) - - -@pytest.fixture(name="bypass_throttle") -def mock_bypass_throttle() -> Generator[None]: - """Fixture to bypass the throttle decorator in __init__.""" - with patch( - "homeassistant.components.home_connect.update_all_devices", - side_effect=bypass_throttle, - ): - yield - - @pytest.fixture(name="integration_setup") async def mock_integration_setup( hass: HomeAssistant, platforms: list[Platform], config_entry: MockConfigEntry, -) -> Callable[[], Awaitable[bool]]: +) -> Callable[[MagicMock], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - async def run() -> bool: - with patch("homeassistant.components.home_connect.PLATFORMS", platforms): + async def run(client: MagicMock) -> bool: + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch( + "homeassistant.components.home_connect.HomeConnectClient" + ) as client_mock, + ): + client_mock.return_value = client result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return result @@ -135,125 +145,205 @@ async def mock_integration_setup( return run -@pytest.fixture(name="get_appliances") -def mock_get_appliances() -> Generator[MagicMock]: - """Mock ConfigEntryAuth parent (HomeAssistantAPI) method.""" - with patch( - "homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances", - ) as mock: - yield mock +def _get_set_program_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey +): + """Set program side effect.""" + + async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=str(kwargs["program_key"]), + ), + *[ + Event( + key=(option_event := EventKey(option.key)), + raw_key=option_event.value, + timestamp=0, + level="", + handling="", + value=str(option.key), + ) + for option in cast( + list[Option], kwargs.get("options", []) + ) + ], + ] + ), + ), + ] + ) + + return set_program_side_effect -@pytest.fixture(name="appliance") -def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: +def _get_set_key_value_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +): + """Set program options side effect.""" + + async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs[parameter_key]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + + return set_key_value_side_effect + + +async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: + """Get available programs.""" + appliance_type = next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + + +async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + +@pytest.fixture(name="client") +def mock_client(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect.""" + + mock = MagicMock( + autospec=HomeConnectClient, + ) + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def add_events(events: list[EventMessage]) -> None: + await event_queue.put(events) + + mock.add_events = add_events + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + mock.start_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + ) + ) + mock.set_selected_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM + ), + ) + mock.set_active_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_selected_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_setting = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + ) + mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) + mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) + mock.get_available_programs = AsyncMock( + side_effect=_get_available_programs_side_effect + ) + mock.put_command = AsyncMock() + + mock.side_effect = mock + return mock + + +@pytest.fixture(name="client_with_exception") +def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect that raise exceptions.""" + mock = MagicMock( + autospec=HomeConnectClient, + ) + + exception = HomeConnectError() + if hasattr(request, "param") and request.param: + exception = request.param + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + + mock.start_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.set_selected_program = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) + mock.set_setting = AsyncMock(side_effect=exception) + mock.get_settings = AsyncMock(side_effect=exception) + mock.get_setting = AsyncMock(side_effect=exception) + mock.get_status = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.put_command = AsyncMock(side_effect=exception) + + return mock + + +@pytest.fixture(name="appliance_ha_id") +def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: """Fixture to mock Appliance.""" app = "Washer" if hasattr(request, "param") and request.param: app = request.param - - mock = MagicMock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.return_value = {} - mock.get_programs_available.return_value = [] - mock.get_status.return_value = {} - mock.get_settings.return_value = {} - - return mock - - -@pytest.fixture(name="problematic_appliance") -def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: - """Fixture to mock a problematic Appliance.""" - app = "Washer" - if hasattr(request, "param") and request.param: - app = request.param - - mock = Mock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.side_effect = HomeConnectError - mock.get_programs_active.side_effect = HomeConnectError - mock.get_programs_available.side_effect = HomeConnectError - mock.start_program.side_effect = HomeConnectError - mock.select_program.side_effect = HomeConnectError - mock.pause_program.side_effect = HomeConnectError - mock.stop_program.side_effect = HomeConnectError - mock.set_options_active_program.side_effect = HomeConnectError - mock.set_options_selected_program.side_effect = HomeConnectError - mock.get_status.side_effect = HomeConnectError - mock.get_settings.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.execute_command.side_effect = HomeConnectError - - return mock - - -def get_all_appliances(): - """Return a list of `HomeConnectAppliance` instances for all appliances.""" - - appliances = {} - - data = load_json_object_fixture("home_connect/appliances.json").get("data") - programs_active = load_json_object_fixture("home_connect/programs-active.json") - programs_available = load_json_object_fixture( - "home_connect/programs-available.json" - ) - - def listen_callback(mock, callback): - callback["callback"](mock) - - for home_appliance in data["homeappliances"]: - api_status = load_json_object_fixture("home_connect/status.json") - api_settings = load_json_object_fixture("home_connect/settings.json") - - ha_id = home_appliance["haId"] - ha_type = home_appliance["type"] - - appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance) - appliance.name = home_appliance["name"] - appliance.listen_events.side_effect = ( - lambda app=appliance, **x: listen_callback(app, x) - ) - appliance.get_programs_active.return_value = programs_active.get( - ha_type, {} - ).get("data", {}) - appliance.get_programs_available.return_value = [ - program["key"] - for program in programs_available.get(ha_type, {}) - .get("data", {}) - .get("programs", []) - ] - appliance.get_status.return_value = HomeConnectAppliance.json2dict( - api_status.get("data", {}).get("status", []) - ) - appliance.get_settings.return_value = HomeConnectAppliance.json2dict( - api_settings.get(ha_type, {}).get("data", {}).get("settings", []) - ) - setattr(appliance, "status", {}) - appliance.status.update(appliance.get_status.return_value) - appliance.status.update(appliance.get_settings.return_value) - appliance.set_setting.side_effect = ( - lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}}) - ) - appliance.start_program.side_effect = ( - lambda x, appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {"value": x}} - ) - ) - appliance.stop_program.side_effect = ( - lambda appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {}} - ) - ) - - appliances[ha_id] = appliance - - return list(appliances.values()) + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.type == app: + return appliance.ha_id + raise ValueError(f"Appliance {app} not found") diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 1b9bec57276..a357d8fb43e 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -2,6 +2,11 @@ "Dishwasher": { "data": { "settings": [ + { + "key": "BSH.Common.Setting.ChildLock", + "value": false, + "type": "Boolean" + }, { "key": "BSH.Common.Setting.AmbientLightEnabled", "value": true, @@ -26,7 +31,13 @@ { "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", - "type": "BSH.Common.EnumType.PowerState" + "type": "BSH.Common.EnumType.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.On", + "BSH.Common.EnumType.PowerState.Off" + ] + } }, { "key": "BSH.Common.Setting.ChildLock", @@ -92,6 +103,11 @@ "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", "type": "BSH.Common.EnumType.PowerState" + }, + { + "key": "BSH.Common.Setting.AlarmClock", + "value": 0, + "type": "Integer" } ] } @@ -154,6 +170,12 @@ "max": 100, "access": "readWrite" } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + "value": 8, + "unit": "°C", + "type": "Double" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3131eac52f..f3c73a32d95 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,255 +2,209 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/00', + 'ha_id': 'BOSCH-000000000-000000000000', + 'name': 'DNE', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'DNE', + 'vib': 'HCS000000', }), 'BOSCH-HCS000000-D00000000001': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/01', + 'ha_id': 'BOSCH-HCS000000-D00000000001', + 'name': 'WasherDryer', 'programs': list([ 'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.Washer.Option.Temperature', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'WasherDryer', + 'vib': 'HCS000001', }), 'BOSCH-HCS000000-D00000000002': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/02', + 'ha_id': 'BOSCH-HCS000000-D00000000002', + 'name': 'Refrigerator', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Refrigerator', + 'vib': 'HCS000002', }), 'BOSCH-HCS000000-D00000000003': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/03', + 'ha_id': 'BOSCH-HCS000000-D00000000003', + 'name': 'Freezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Freezer', + 'vib': 'HCS000003', }), 'BOSCH-HCS000000-D00000000004': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/04', + 'ha_id': 'BOSCH-HCS000000-D00000000004', + 'name': 'Hood', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ColorTemperature': dict({ - 'type': 'BSH.Common.EnumType.ColorTemperature', - 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Cooking.Common.Setting.Lighting': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Cooking.Common.Setting.LightingBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'Cooking.Common.Setting.Lighting': True, + 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, + 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hood', + 'vib': 'HCS000004', }), 'BOSCH-HCS000000-D00000000005': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-D00000000005', + 'name': 'Hob', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', }), 'BOSCH-HCS000000-D00000000006': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': 'BOSCH-HCS000000-D00000000006', + 'name': 'CookProcessor', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS01OVN1/03', + 'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245', + 'name': 'Oven', 'programs': list([ 'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AlarmClock': 0, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Oven', + 'vib': 'HCS01OVN1', }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS04DYR1/03', + 'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A', + 'name': 'Dryer', 'programs': list([ 'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Synthetic', 'LaundryCare.Dryer.Program.Mix', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dryer', + 'vib': 'HCS04DYR1', }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS06COM1/03', + 'ha_id': 'BOSCH-HCS06COM1-D70390681C2C', + 'name': 'CoffeeMaker', 'programs': list([ 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', @@ -259,26 +213,24 @@ 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CoffeeMaker', + 'vib': 'HCS06COM1', }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -286,51 +238,30 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS03WCH1/03', + 'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794', + 'name': 'Washer', 'programs': list([ 'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.EasyCare', @@ -338,97 +269,55 @@ 'LaundryCare.Washer.Program.DelicatesSilk', 'LaundryCare.Washer.Program.Wool', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'BSH.Common.Root.ActiveProgram', - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Washer', + 'vib': 'HCS03WCH1', }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS05FRF1/03', + 'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D', + 'name': 'FridgeFreezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ - 'constraints': dict({ - 'access': 'readWrite', - 'max': 100, - 'min': 0, - }), - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Setting.Light.External.Power': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), + 'settings': dict({ + 'Refrigeration.Common.Setting.Dispenser.Enabled': False, + 'Refrigeration.Common.Setting.Light.External.Brightness': 70, + 'Refrigeration.Common.Setting.Light.External.Power': True, + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'FridgeFreezer', + 'vib': 'HCS05FRF1', }), }) # --- # name: test_async_get_device_diagnostics dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -436,47 +325,22 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }) # --- diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 8e108cc2b0a..182051ad64a 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,32 +1,29 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectAPI +from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_component import async_update_entity +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry @pytest.fixture @@ -35,123 +32,166 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test binary sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("state", "expected"), + ("value", "expected"), [ (BSH_DOOR_STATE_CLOSED, "off"), (BSH_DOOR_STATE_LOCKED, "off"), (BSH_DOOR_STATE_OPEN, "on"), - ("", "unavailable"), + ("", STATE_UNKNOWN), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors_door_states( + appliance_ha_id: str, expected: str, - state: str, + value: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Tests for Appliance door states.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": state}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), [ + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + False, + STATE_OFF, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + True, + STATE_ON, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + "", + STATE_UNKNOWN, + "Washer", + ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_CLOSED, STATE_OFF, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_OPEN, STATE_ON, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, "", - STATE_UNAVAILABLE, + STATE_UNKNOWN, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_bianry_sensors_fridge_door_states( +async def test_binary_sensors_functionality( entity_id: str, - status_key: str, + event_key: EventKey, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( @@ -189,8 +229,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 80f53e20b39..c015a881343 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest from homeassistant import config_entries, setup @@ -10,11 +11,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py new file mode 100644 index 00000000000..51f42a98f42 --- /dev/null +++ b/tests/components/home_connect/test_coordinator.py @@ -0,0 +1,367 @@ +"""Test for Home Connect coordinator.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, + BSH_EVENT_PRESENT_STATE_PRESENT, + BSH_POWER_OFF, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntryState +from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.core import ( + Event as HassEvent, + EventStateReportedData, + HomeAssistant, + callback, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] + + +async def test_coordinator_update( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the coordinator can update.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_update_failing_get_appliances( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_update_failing_get_settings_status( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that although is not possible to get settings and status, the config entry is loaded. + + This is for cases where some appliances are reachable and some are not in the same configuration entry. + """ + # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + ("event_type", "event_key", "event_value", "entity_id"), + [ + ( + EventType.STATUS, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "sensor.dishwasher_door", + ), + ( + EventType.NOTIFY, + EventKey.BSH_COMMON_SETTING_POWER_STATE, + BSH_POWER_OFF, + "switch.dishwasher_power", + ), + ( + EventType.EVENT, + EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + BSH_EVENT_PRESENT_STATE_PRESENT, + "sensor.dishwasher_salt_nearly_empty", + ), + ], +) +async def test_event_listener( + event_type: EventType, + event_key: EventKey, + event_value: str, + entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the event listener works.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + event_message = EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + new_state = hass.states.get(entity_id) + assert new_state + assert new_state.state != state.state + + # Following, we are gonna check that the listeners are clean up correctly + new_entity_id = entity_id + "_new" + listener = MagicMock() + + @callback + def listener_callback(event: HassEvent[EventStateReportedData]) -> None: + listener(event.data["entity_id"]) + + @callback + def event_filter(_: EventStateReportedData) -> bool: + return True + + hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + + entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) + await hass.async_block_till_done() + await client.add_events([event_message]) + await hass.async_block_till_done() + + # Because the entity's id has been updated, the entity has been unloaded + # and the listener has been removed, and the new entity adds a new listener, + # so the only entity that should report states is the one with the new entity id + listener.assert_called_once_with(new_entity_id) + + +async def tests_receive_setting_and_status_for_first_time_at_events( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is capable of receiving settings and status for the first time.""" + client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) + client.get_status = AsyncMock(return_value=ArrayOfStatus([])) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL, + raw_key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert len(config_entry._background_tasks) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_event_listener_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the configuration entry is reloaded when the event stream raises an API error.""" + client_with_exception.stream_all_events = MagicMock( + side_effect=HomeConnectApiError("error.key", "error description") + ) + + with patch.object( + ConfigEntries, + "async_schedule_reload", + ) as mock_schedule_reload: + await integration_setup(client_with_exception) + await hass.async_block_till_done() + + client_with_exception.stream_all_events.assert_called_once() + mock_schedule_reload.assert_called_once_with(config_entry.entry_id) + assert not config_entry._background_tasks + + +@pytest.mark.parametrize( + "exception", + [HomeConnectRequestError(), EventStreamInterruptedError()], +) +@pytest.mark.parametrize( + ( + "entity_id", + "initial_state", + "status_key", + "status_value", + "after_refresh_expected_state", + "event_key", + "event_value", + "after_event_expected_state", + ), + [ + ( + "sensor.washer_door", + "closed", + StatusKey.BSH_COMMON_DOOR_STATE, + BSH_DOOR_STATE_LOCKED, + "locked", + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "open", + ), + ], +) +@patch( + "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 +) +async def test_event_listener_resilience( + entity_id: str, + initial_state: str, + status_key: StatusKey, + status_value: Any, + after_refresh_expected_state: str, + event_key: EventKey, + event_value: Any, + after_event_expected_state: str, + exception: HomeConnectError, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is resilient to interruptions.""" + future = hass.loop.create_future() + + async def stream_exception(): + yield await future + + client.stream_all_events = MagicMock( + side_effect=[stream_exception(), client.stream_all_events()] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(config_entry._background_tasks) == 1 + + assert hass.states.is_state(entity_id, initial_state) + + client.get_status.return_value = ArrayOfStatus( + [Status(key=status_key, raw_key=status_key.value, value=status_value)], + ) + await hass.async_block_till_done() + future.set_exception(exception) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert client.stream_all_events.call_count == 2 + assert hass.states.is_state(entity_id, after_refresh_expected_state) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, after_event_expected_state) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index f2db6e2b67a..ab6823411dc 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -1,11 +1,9 @@ """Test diagnostics for Home Connect.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError -import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.diagnostics import ( @@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import get_all_appliances - from tests.common import MockConfigEntry -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_device_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device = device_registry.async_get_or_create( @@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics( ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot - - -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "Random-Device-ID")}, - ) - - with pytest.raises(ValueError): - await async_get_device_diagnostics(hass, config_entry, device) - - -@pytest.mark.parametrize( - ("api_error", "expected_connection_status"), - [ - (HomeConnectError(), "unknown"), - ( - HomeConnectError( - { - "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed", - } - ), - "offline", - ), - ], -) -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_api_error( - api_error: HomeConnectError, - expected_connection_status: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - appliance.get_programs_available.side_effect = api_error - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, - ) - - diagnostics = await async_get_device_diagnostics(hass, config_entry, device) - assert diagnostics["programs"] is None diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 69601efb42d..f62feca700a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -2,27 +2,18 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +from aiohomeconnect.const import OAUTH2_TOKEN +from aiohomeconnect.model import SettingKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from requests import HTTPError import requests_mock +import respx from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import ( - SCAN_INTERVAL, - bsh_key_to_translation_key, -) -from homeassistant.components.home_connect.const import ( - BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, - BSH_POWER_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, - COOKING_LIGHTING, - DOMAIN, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -39,7 +30,6 @@ from .conftest import ( FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, - get_all_appliances, ) from tests.common import MockConfigEntry @@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [ ] SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_options_active_program", - "set_option_selected": "set_options_selected_program", + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", "change_setting": "set_setting", - "pause_program": "execute_command", - "resume_program": "execute_command", - "select_program": "select_program", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", "start_program": "start_program", } -@pytest.mark.usefixtures("bypass_throttle") -async def test_api_setup( +async def test_entry_setup( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test setup and unload.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -156,72 +144,60 @@ async def test_api_setup( assert config_entry.state == ConfigEntryState.NOT_LOADED -async def test_update_throttle( - appliance: Mock, - freezer: FrozenDateTimeFactory, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, -) -> None: - """Test to check Throttle functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - get_appliances_call_count = get_appliances.call_count - - # First re-load after 1 minute is not blocked. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds + 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - # Second re-load is blocked by Throttle. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds - 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - -@pytest.mark.usefixtures("bypass_throttle") async def test_exception_handling( - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) -@pytest.mark.usefixtures("bypass_throttle") +@respx.mock async def test_token_refresh_success( - integration_setup: Callable[[], Awaitable[bool]], + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, requests_mock: requests_mock.Mocker, setup_credentials: None, + client: MagicMock, ) -> None: """Test where token is expired and the refresh attempt succeeds.""" assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) - requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}}) - aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) - assert await integration_setup() + appliances = client.get_home_appliances.return_value + + async def mock_get_home_appliances(): + await client._auth.async_get_access_token() + return appliances + + client.get_home_appliances.return_value = None + client.get_home_appliances.side_effect = mock_get_home_appliances + + def init_side_effect(auth) -> MagicMock: + client._auth = auth + return client + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, + ): + client_mock.side_effect = MagicMock(side_effect=init_side_effect) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED # Verify token request @@ -240,45 +216,43 @@ async def test_token_refresh_success( ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_http_error( +async def test_client_error( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: - """Test HTTP errors during setup integration.""" - get_appliances.side_effect = HTTPError(response=MagicMock()) + """Test client errors during setup integration.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - assert get_appliances.call_count == 1 + assert not await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert client_with_exception.get_home_appliances.call_count == 1 @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, ) -> None: """Create and test services.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_name = service_call["service"] @@ -286,8 +260,7 @@ async def test_services( await hass.services.async_call(**service_call) await hass.async_block_till_done() assert ( - getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count - == 1 + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 ) @@ -295,26 +268,24 @@ async def test_services( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, + appliance_ha_id: str, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, problematic_appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -323,25 +294,47 @@ async def test_services_exception( await hass.services.async_call(**service_call) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + device_registry: dr.DeviceRegistry, ) -> None: """Raise a ServiceValidationError when device id does not match.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED service_call = SERVICE_KV_CALL_PARAMS[0] service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises( + ServiceValidationError, match=r"Home Connect config entry.*not found" + ): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) @@ -351,7 +344,7 @@ async def test_entity_migration( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance: Mock, + appliance_ha_id: str, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -360,34 +353,39 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) test_entities = [ ( SENSOR_DOMAIN, "Operation State", - BSH_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE, ), ( SWITCH_DOMAIN, "ChildLock", - BSH_CHILD_LOCK_STATE, + SettingKey.BSH_COMMON_CHILD_LOCK, ), ( SWITCH_DOMAIN, "Power", - BSH_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE, ), ( BINARY_SENSOR_DOMAIN, "Remote Start", - BSH_REMOTE_START_ALLOWANCE_STATE, + StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, ), ( LIGHT_DOMAIN, "Light", - COOKING_LIGHTING, + SettingKey.COOKING_COMMON_LIGHTING, + ), + ( # An already migrated entity + SWITCH_DOMAIN, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, ), ] @@ -395,7 +393,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance.haId}-{old_unique_id_suffix}", + f"{appliance_ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -406,7 +404,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 471ddf0ec54..4f8cb60d881 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -1,20 +1,24 @@ """Tests for home_connect light entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import MagicMock, call -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError import pytest from homeassistant.components.home_connect.const import ( - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, + BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,26 +27,15 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, - STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Hood" -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get(TEST_HC_APP) - .get("data") - .get("settings") -} - @pytest.fixture def platforms() -> list[str]: @@ -51,29 +44,31 @@ def platforms() -> list[str]: async def test_light( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("entity_id", "status", "service", "service_data", "state", "appliance"), + ( + "entity_id", + "set_settings_args", + "service", + "exprected_attributes", + "state", + "appliance_ha_id", + ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, @@ -83,58 +78,18 @@ async def test_light( ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 200}, + {"brightness": 199}, STATE_ON, "Hood", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_OFF, - {}, - STATE_OFF, - "Hood", - ), - ( - "light.hood_functional_light", - { - COOKING_LIGHTING: { - "value": None, - }, - COOKING_LIGHTING_BRIGHTNESS: None, - }, - SERVICE_TURN_ON, - {}, - STATE_UNKNOWN, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_ON, - {"brightness": 200}, - STATE_ON, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: {"value": False}, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, @@ -144,8 +99,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, + }, + SERVICE_TURN_ON, + {"brightness": 199}, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False, + }, + SERVICE_TURN_OFF, + {}, + STATE_OFF, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, }, SERVICE_TURN_ON, {}, @@ -155,15 +130,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_COLOR: { - "value": "", - }, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, { - "rgb_color": [255, 255, 0], + "rgb_color": (255, 255, 0), + }, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, }, STATE_ON, "Hood", @@ -171,10 +159,7 @@ async def test_light( ( "light.fridgefreezer_external_light", { - REFRIGERATION_EXTERNAL_LIGHT_POWER: { - "value": True, - }, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75}, + SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True, }, SERVICE_TURN_ON, {}, @@ -182,167 +167,268 @@ async def test_light( "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_light_functionality( entity_id: str, - status: dict, + set_settings_args: dict[SettingKey, Any], service: str, - service_data: dict, + exprected_attributes: dict[str, Any], state: str, - appliance: Mock, - bypass_throttle: Generator[None], + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test light functionality.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) + service_data = exprected_attributes.copy() service_data["entity_id"] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, - service_data, - blocking=True, + {key: value for key, value in service_data.items() if value is not None}, ) - assert hass.states.is_state(entity_id, state) + await hass.async_block_till_done() + client.set_setting.assert_has_calls( + [ + call(appliance_ha_id, setting_key=setting_key, value=value) + for setting_key, value in set_settings_args.items() + ] + ) + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == state + for key, value in exprected_attributes.items(): + assert entity_state.attributes[key] == value @pytest.mark.parametrize( ( "entity_id", - "status", + "events", + "appliance_ha_id", + ), + [ + ( + "light.hood_ambient_light", + { + EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1", + }, + "Hood", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_light_color_different_than_custom( + entity_id: str, + events: dict[EventKey, Any], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that light color attributes are not set if color is different than custom.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "rgb_color": (255, 255, 0), + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is not None + assert entity_state.attributes["hs_color"] is not None + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + for event_key, value in events.items() + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is None + assert entity_state.attributes["hs_color"] is None + + +@pytest.mark.parametrize( + ( + "entity_id", + "setting", "service", "service_data", - "mock_attr", "attr_side_effect", - "problematic_appliance", "exception_match", ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": False, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*off.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, None, HomeConnectError], - "Hood", + r"Error.*set.*brightness.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, HomeConnectError], + r"Error.*select.*custom color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, None, HomeConnectError], + r"Error.*set.*color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, + }, + [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), ], - indirect=["problematic_appliance"], ) -async def test_switch_exception_handling( +async def test_light_exception_handling( entity_id: str, - status: dict, + setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, - mock_attr: str, - attr_side_effect: list, - problematic_appliance: Mock, + attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" - problematic_appliance.status.update(SETTINGS_STATUS) - problematic_appliance.set_setting.side_effect = attr_side_effect - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value, + ) + for setting_key, value in setting.items() + ] + ) + client_with_exception.set_setting.side_effect = [ + exception() if exception else None for exception in attr_side_effect + ] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await client_with_exception.set_setting() - problematic_appliance.status.update(status) service_data["entity_id"] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect) + assert client_with_exception.set_setting.call_count == len(attr_side_effect) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bce19161cf8..371aed928dd 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -1,22 +1,17 @@ """Tests for home_connect number entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import random -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components.home_connect.const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, -) from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, ATTR_VALUE as SERVICE_ATTR_VALUE, + DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -26,8 +21,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -38,25 +31,24 @@ def platforms() -> list[str]: async def test_number( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test number entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Refrigerator"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", "setting_key", + "type", + "expected_state", "min_value", "max_value", "step_size", @@ -64,102 +56,132 @@ async def test_number( ), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, 7, 15, 0.1, "°C", ), + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, + 7, + 15, + 5, + "°C", + ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, + type: str, + expected_state: int, min_value: int, max_value: int, step_size: float, unit_of_measurement: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test number entity functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_MIN: min_value, - ATTR_MAX: max_value, - ATTR_STEPSIZE: step_size, - }, - ATTR_UNIT: unit_of_measurement, - } - ] - get_appliances.return_value = [appliance] - current_value = min_value - appliance.status.update({setting_key: {ATTR_VALUE: current_value}}) + client.get_setting.side_effect = None + client.get_setting = AsyncMock( + return_value=GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # This should not change the value + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size if isinstance(step_size, int) else None, + ), + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, str(current_value)) - state = hass.states.get(entity_id) - assert state.attributes["min"] == min_value - assert state.attributes["max"] == max_value - assert state.attributes["step"] == step_size - assert state.attributes["unit_of_measurement"] == unit_of_measurement + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == str(expected_state) + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement - new_value = random.randint(min_value + 1, max_value) + value = random.choice( + [num for num in range(min_value, max_value + 1) if num != expected_state] + ) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - SERVICE_ATTR_VALUE: new_value, + SERVICE_ATTR_VALUE: value, }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("problematic_appliance", ["Refrigerator"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test number entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=DEFAULT_MIN_VALUE, + constraints=SettingConstraints( + min=int(DEFAULT_MIN_VALUE), + max=int(DEFAULT_MAX_VALUE), + step_size=1, + ), + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -173,4 +195,4 @@ async def test_number_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index af975979196..6ebd37266cd 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,39 +1,38 @@ """Tests for home_connect select entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + ProgramKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram import pytest -from homeassistant.components.home_connect.const import ( - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, -) from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Washer") - .get("data") - .get("settings") -} - -PROGRAM = "Dishcare.Dishwasher.Program.Eco50" +from tests.common import MockConfigEntry @pytest.fixture @@ -43,119 +42,148 @@ def platforms() -> list[str]: async def test_select( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test select entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED async def test_filter_unknown_programs( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, - appliance: Mock, + client: MagicMock, entity_registry: er.EntityRegistry, ) -> None: - """Test select that programs that are not part of the official Home Connect API specification are filtered out. - - We use two programs to ensure that programs are iterated over a copy of the list, - and it does not raise problems when removing an element from the original list. - """ - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [ - PROGRAM, - "NonOfficialProgram", - "AntotherNonOfficialProgram", - ] - get_appliances.return_value = [appliance] + """Test select that only known programs are shown.""" + client.get_available_programs.side_effect = None + client.get_available_programs.return_value = ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ), + EnumerateAvailableProgram( + key=ProgramKey.UNKNOWN, + raw_key="an unknown program", + ), + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - entity = entity_registry.async_get("select.washer_selected_program") + entity = entity_registry.async_get("select.dishwasher_selected_program") assert entity - assert entity.capabilities.get(ATTR_OPTIONS) == [ - "dishcare_dishwasher_program_eco_50" - ] + assert entity.capabilities + assert entity.capabilities[ATTR_OPTIONS] == ["dishcare_dishwasher_program_eco_50"] @pytest.mark.parametrize( - ("entity_id", "status", "program_to_set"), + ( + "appliance_ha_id", + "entity_id", + "mock_method", + "program_key", + "program_to_set", + "event_key", + ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_selected_program", + "set_selected_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_active_program", + "start_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], + indirect=["appliance_ha_id"], ) -async def test_select_functionality( +async def test_select_program_functionality( + appliance_ha_id: str, entity_id: str, - status: dict, + mock_method: str, + program_key: ProgramKey, program_to_set: str, - bypass_throttle: Generator[None], + event_key: EventKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test select functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - appliance.status.update(status) + assert hass.states.is_state(entity_id, "unknown") await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, - blocking=True, + ) + await hass.async_block_till_done() + getattr(client, mock_method).assert_awaited_once_with( + appliance_ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value="A not known program", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + @pytest.mark.parametrize( ( "entity_id", - "status", "program_to_set", "mock_attr", "exception_match", ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_selected_program", "dishcare_dishwasher_program_eco_50", - "select_program", + "set_selected_program", r"Error.*select.*program.*", ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_active_program", "dishcare_dishwasher_program_eco_50", "start_program", r"Error.*start.*program.*", @@ -164,32 +192,36 @@ async def test_select_functionality( ) async def test_select_exception_handling( entity_id: str, - status: dict, program_to_set: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() - problematic_appliance.status.update(status) with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SELECT_DOMAIN, @@ -197,4 +229,4 @@ async def test_select_exception_handling( {"entity_id": entity_id, "option": program_to_set}, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f2ee3b13922..ce06a841bbb 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,75 +1,77 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) from freezegun.api import FrozenDateTimeFactory -from homeconnect.api import HomeConnectAPI import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_PRESENT, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Dishwasher" EVENT_PROG_DELAYED_START = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" - }, -} - -EVENT_PROG_REMAIN_NO_VALUE = { - "BSH.Common.Option.RemainingProgramTime": {}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart", }, } EVENT_PROG_RUN = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "60"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", + }, + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 60, }, } - EVENT_PROG_UPDATE_1 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "80"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_UPDATE_2 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "20"}, - "BSH.Common.Option.ProgramProgress": {"value": "99"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_END = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Ready" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready", }, } @@ -80,22 +82,19 @@ def platforms() -> list[str]: return [Platform.SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -# Appliance program sequence with a delayed start. +# Appliance_ha_id program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -130,7 +129,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -141,17 +140,16 @@ ENTITY_ID_STATES = { ) ), ) -@pytest.mark.usefixtures("bypass_throttle") async def test_event_sensors( - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, states: tuple, - event_run: dict, + event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, ) -> None: """Test sequence for sensors that are only available after an event happens.""" entity_ids = ENTITY_ID_STATES.keys() @@ -159,24 +157,48 @@ async def test_event_sensors( time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_DELAYED_START) - assert await integration_setup() + client.get_status.return_value.status.extend( + Status( + key=StatusKey(event_key.value), + raw_key=event_key.value, + value=value, + ) + for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() + ) + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(event_run) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event_run.items() + for event_key, value in events.items() + ] + ) + await hass.async_block_till_done() for entity_id, state in zip(entity_ids, states, strict=False): - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ - EVENT_PROG_REMAIN_NO_VALUE, + EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, EVENT_PROG_END, EVENT_PROG_END, @@ -191,60 +213,86 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) -@pytest.mark.usefixtures("bypass_throttle") +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance: Mock, + appliance_ha_id: str, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" - get_appliances.return_value = [appliance] entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED for ( event, expected_state, ) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False): - appliance.status.update(event) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event.items() + for event_key, value in events.items() + ] + ) await hass.async_block_till_done() freezer.tick() assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ( + "entity_id", + "event_key", + "event_type", + "event_value_update", + "expected", + "appliance_ha_id", + ), [ ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_LOCKED, "locked", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_CLOSED, "closed", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_OPEN, "open", "Dishwasher", @@ -252,33 +300,38 @@ async def test_remaining_prog_time_edge_cases( ( "sensor.fridgefreezer_freezer_door_alarm", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + EventType.EVENT, "", "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "FridgeFreezer", ), ( "sensor.coffeemaker_bean_container_empty", + EventType.EVENT, "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "", "off", @@ -286,52 +339,68 @@ async def test_remaining_prog_time_edge_cases( ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "CoffeeMaker", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors_states( entity_id: str, - status_key: str, + event_key: EventKey, + event_type: EventType, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: - """Tests for Appliance alarm sensors.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] + """Tests for Appliance_ha_id alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ), + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 80bfcf9db96..10d393423be 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,24 +1,34 @@ """Tests for home_connect sensor entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfSettings, + Event, + EventKey, + EventMessage, + GetSetting, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +from aiohomeconnect.model.program import ( + ArrayOfAvailablePrograms, + EnumerateAvailableProgram, +) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -36,19 +46,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Dishwasher") - .get("data") - .get("settings") -} - -PROGRAM = "LaundryCare.Dryer.Program.Mix" +from tests.common import MockConfigEntry @pytest.fixture @@ -58,231 +56,285 @@ def platforms() -> list[str]: async def test_switches( - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), - [ - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": ""}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": True}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": False}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ], - indirect=["appliance"], -) -async def test_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, - bypass_throttle: Generator[None], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, -) -> None: - """Test switch functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True - ) - assert hass.states.is_state(entity_id, state) - - @pytest.mark.parametrize( ( "entity_id", - "status", + "service", + "settings_key_arg", + "setting_value_arg", + "state", + "appliance_ha_id", + ), + [ + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_ON, + SettingKey.BSH_COMMON_CHILD_LOCK, + True, + STATE_ON, + "Dishwasher", + ), + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_OFF, + SettingKey.BSH_COMMON_CHILD_LOCK, + False, + STATE_OFF, + "Dishwasher", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_switch_functionality( + entity_id: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + service: str, + state: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "program_key", "appliance_ha_id"), + [ + ( + "switch.dryer_program_mix", + ProgramKey.LAUNDRY_CARE_DRYER_MIX, + "Dryer", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_program_switch_functionality( + entity_id: str, + program_key: ProgramKey, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + async def mock_stop_program(ha_id: str) -> None: + """Mock stop program.""" + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ), + ] + ) + + client.stop_program = AsyncMock(side_effect=mock_stop_program) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_ON) + client.start_program.assert_awaited_once_with( + appliance_ha_id, program_key=program_key + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_OFF) + client.stop_program.assert_awaited_once_with(appliance_ha_id) + + +@pytest.mark.parametrize( + ( + "entity_id", "service", "mock_attr", - "problematic_appliance", "exception_match", ), [ ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_ON, "start_program", - "Dishwasher", r"Error.*start.*program.*", ), ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_OFF, "stop_program", - "Dishwasher", r"Error.*stop.*program.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], ) async def test_switch_exception_handling( entity_id: str, - status: dict, service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_CHILD_LOCK, + raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value, + value=False, + ), + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=[BSH_POWER_ON, BSH_POWER_OFF] + ), + ), + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - problematic_appliance.status.update(status) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), + ("entity_id", "status", "service", "state", "appliance_ha_id"), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_functionality( entity_id: str, status: dict, service: str, state: str, - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) @@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "problematic_appliance", + "appliance_ha_id", "exception_match", ), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, "set_setting", "FridgeFreezer", @@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality( ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, - status: dict, + status: dict[SettingKey, str], service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" - problematic_appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(problematic_appliance.name) - .get("data") - .get("settings") - ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=key, + raw_key=key.value, + value=value, + ) + for key, value in status.items() + ] ) - get_appliances.return_value = [problematic_appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() - - problematic_appliance.status.update(status) + await client_with_exception.set_setting() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert client_with_exception.set_setting.call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "allowed_values", "service", "power_state", "appliance"), + ( + "entity_id", + "allowed_values", + "service", + "setting_value_arg", + "power_state", + "appliance_ha_id", + ), [ ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_OFF, + BSH_POWER_OFF, STATE_OFF, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_OFF, + BSH_POWER_STANDBY, STATE_OFF, "Dishwasher", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_swtich( entity_id: str, - status: dict, - allowed_values: list[str], + allowed_values: list[str | None] | None, service: str, + setting_value_arg: str, power_state: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test power switch functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - appliance.status.update(status) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value="", + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=setting_value_arg, ) assert hass.states.is_state(entity_id, power_state) @pytest.mark.parametrize( - ("entity_id", "allowed_values", "service", "appliance", "exception_match"), + ("initial_value"), + [ + (BSH_POWER_OFF), + (BSH_POWER_STANDBY), + ], +) +async def test_power_switch_fetch_off_state_from_current_value( + initial_value: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test power switch functionality to fetch the off state from the current value.""" + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=initial_value, + ) + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +@pytest.mark.parametrize( + ("entity_id", "allowed_values", "service", "exception_match"), [ ( "switch.dishwasher_power", [BSH_POWER_ON], SERVICE_TURN_OFF, - "Dishwasher", r".*not support.*turn.*off.*", ), ( "switch.dishwasher_power", None, SERVICE_TURN_OFF, - "Dishwasher", + r".*Unable.*turn.*off.*support.*not.*determined.*", + ), + ( + "switch.dishwasher_power", + HomeConnectError(), + SERVICE_TURN_OFF, r".*Unable.*turn.*off.*support.*not.*determined.*", ), ], - indirect=["appliance"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_switch_service_validation_errors( entity_id: str, - allowed_values: list[str], + allowed_values: list[str | None] | None | HomeConnectError, service: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, exception_match: str, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" - if allowed_values: - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + if isinstance(allowed_values, HomeConnectError): + exception = allowed_values + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + ) + ] + ) + client.get_setting = AsyncMock(side_effect=exception) + else: + setting = GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + client.get_settings.return_value = ArrayOfSettings([setting]) + client.get_setting = AsyncMock(return_value=setting) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}}) - with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, + appliance_ha_id: str, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "switch.washer_program_mix" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( @@ -539,7 +645,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 1401e07b05a..95f9ddeba80 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -1,21 +1,19 @@ """Tests for home_connect time entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import time -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from homeassistant.components.home_connect.const import ATTR_VALUE from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -26,114 +24,98 @@ def platforms() -> list[str]: async def test_time( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test time entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) @pytest.mark.parametrize( - ("entity_id", "setting_key", "setting_value", "expected_state"), + ("entity_id", "setting_key"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: 59}, - str(time(second=59)), - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: None}, - "unknown", - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - None, - "unknown", + SettingKey.BSH_COMMON_ALARM_CLOCK, ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - setting_value: dict, - expected_state: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test time entity functionality.""" - get_appliances.return_value = [appliance] - appliance.status.update({setting_key: setting_value}) - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, expected_state) - new_value = 30 - assert hass.states.get(entity_id).state != new_value + value = 30 + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state != value await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(second=new_value), + ATTR_TIME: time(second=value), }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(time(second=value))) -@pytest.mark.parametrize("problematic_appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", + SettingKey.BSH_COMMON_ALARM_CLOCK, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test time entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=30, + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -147,4 +129,4 @@ async def test_time_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 From 427c437a68654d149a99ead7dbf94c74f6e35773 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:32:10 -0500 Subject: [PATCH 0011/1435] 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 64b056fbe998cf7231906c26f4daab02bb4124a5 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Jan 2025 03:57:36 +0100 Subject: [PATCH 0012/1435] 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 731b1cdeb67..16079cca64d 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 db89f8db9d0..a5bd58dff58 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 a8175b785f1445319cec7edae411a78272d51707 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jan 2025 08:42:23 +0100 Subject: [PATCH 0013/1435] Bump github/codeql-action from 3.28.6 to 3.28.8 (#136890) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.6 to 3.28.8. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.28.6...v3.28.8) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d7f46b176cd..c1272759acc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.6 + uses: github/codeql-action/init@v3.28.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.6 + uses: github/codeql-action/analyze@v3.28.8 with: category: "/language:python" From 97fcbed6e08e3a37eb8d852f695a9d5bdfca514d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 30 Jan 2025 10:07:10 +0100 Subject: [PATCH 0014/1435] Add error handling to enphase_envoy switch platform action (#136837) * Add error handling to enphase_envoy switch platform action * Use decorators for exception handling --- .../components/enphase_envoy/entity.py | 37 ++++- .../components/enphase_envoy/strings.json | 3 + .../components/enphase_envoy/switch.py | 8 +- tests/components/enphase_envoy/test_switch.py | 137 ++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py index 491951625ee..04987d861d2 100644 --- a/homeassistant/components/enphase_envoy/entity.py +++ b/homeassistant/components/enphase_envoy/entity.py @@ -2,13 +2,22 @@ from __future__ import annotations -from pyenphase import EnvoyData +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate +from httpx import HTTPError +from pyenphase import EnvoyData +from pyenphase.exceptions import EnvoyError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import EnphaseUpdateCoordinator +ACTIONERRORS = (EnvoyError, HTTPError) + class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): """Defines a base envoy entity.""" @@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): data = self.coordinator.envoy.data assert data is not None return data + + +def exception_handler[_EntityT: EnvoyBaseEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Enphase Envoy calls to handle exceptions. + + A decorator that wraps the passed in function, catches enphase_envoy errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except ACTIONERRORS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_error", + translation_placeholders={ + "host": self.coordinator.envoy.host, + "args": error.args[0], + "action": func.__name__, + "entity": self.entity_id, + }, + ) from error + + return handler diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 589dc52f71d..e99c45c5c7a 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -400,6 +400,9 @@ }, "envoy_error": { "message": "Error communicating with Envoy API on {host}: {args}" + }, + "action_error": { + "message": "Failed to execute {action} for {entity}, host: {host}: {args}" } } } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 7074f341cc8..8a3ca493562 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert enpower is not None return self.entity_description.value_fn(enpower) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Enpower switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Enpower switch.""" await self.entity_description.turn_off_fn(self.envoy) @@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert relay is not None return self.entity_description.value_fn(relay) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on (close) the dry contact.""" if await self.entity_description.turn_on_fn(self.envoy, self.relay_id): self.async_write_ha_state() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): @@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert self.data.tariff.storage_settings is not None return self.entity_description.value_fn(self.data.tariff.storage_settings) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the storage settings switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the storage switch.""" await self.entity_description.turn_off_fn(self.envoy) diff --git a/tests/components/enphase_envoy/test_switch.py b/tests/components/enphase_envoy/test_switch.py index f30cba4d201..d15c0ad740f 100644 --- a/tests/components/enphase_envoy/test_switch.py +++ b/tests/components/enphase_envoy/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -16,6 +17,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -112,6 +114,46 @@ async def test_switch_grid_operation( mock_envoy.go_off_grid.reset_mock() +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +async def test_switch_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test switch platform operation for grid switches when error occurs.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.data.enpower.serial_number + test_entity = f"{Platform.SWITCH}.enpower_{sn}_grid_enabled" + + mock_envoy.go_off_grid.side_effect = EnvoyError("Test") + mock_envoy.go_on_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation( mock_envoy.disable_charge_from_grid.reset_mock() +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_switch_charge_from_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test switch platform operation for charge from grid switches.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid" + + mock_envoy.disable_charge_from_grid.side_effect = EnvoyError("Test") + mock_envoy.enable_charge_from_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "entity_states"), [ @@ -232,3 +321,51 @@ async def test_switch_relay_operation( assert mock_envoy.close_dry_contact.await_count == close_count mock_envoy.open_dry_contact.reset_mock() mock_envoy.close_dry_contact.reset_mock() + + +@pytest.mark.parametrize( + ("mock_envoy", "relay"), + [("envoy_metered_batt_relay", "NC1")], + indirect=["mock_envoy"], +) +async def test_switch_relay_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + relay: str, +) -> None: + """Test enphase_envoy switch relay entities operation.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SWITCH}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}" + + mock_envoy.close_dry_contact.side_effect = EnvoyError("Test") + mock_envoy.open_dry_contact.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) From 708ae09c7abd1a4a91fa6dfbeb4aacbc392f78fe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 30 Jan 2025 01:07:55 -0800 Subject: [PATCH 0015/1435] 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 16079cca64d..90a8709395f 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.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 a5bd58dff58..c7a0959bbb8 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.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 1b5316b269ac69f43988653664aea774f3796149 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:07 +0100 Subject: [PATCH 0016/1435] 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 52feeedd2b3d36c347dd9a860b0ee638b4513d63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:31 +0100 Subject: [PATCH 0017/1435] 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 9eb383f3148c559995631bc4ae44269f67d9f3cd Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 21:11:40 +1100 Subject: [PATCH 0018/1435] Bump Pysmlight to v0.2.0 (#136886) * Bump pysmlight to v0.2.0 * Update info.json fixture with radios list * Update diagnostics snapshot --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smlight/fixtures/info.json | 14 +++++++++++++- .../smlight/snapshots/test_diagnostics.ambr | 18 ++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3a8578c8a59..9410e54cee1 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.6"], + "requirements": ["pysmlight==0.2.0"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 90a8709395f..5cea3cd444e 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.2.0 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7a0959bbb8..a6c6271c39a 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.2.0 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index e3defb4410e..b94fdc3d61c 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -15,5 +15,17 @@ "zb_hw": "CC2652P7", "zb_ram_size": 152, "zb_version": "20240314", - "zb_type": 0 + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "CC2652P7", + "zb_version": "20240314", + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] } diff --git a/tests/components/smlight/snapshots/test_diagnostics.ambr b/tests/components/smlight/snapshots/test_diagnostics.ambr index 97177de1704..5ee6cd19676 100644 --- a/tests/components/smlight/snapshots/test_diagnostics.ambr +++ b/tests/components/smlight/snapshots/test_diagnostics.ambr @@ -10,6 +10,24 @@ 'hostname': 'SLZB-06p7', 'legacy_api': 0, 'model': 'SLZB-06p7', + 'radios': list([ + dict({ + 'chip_index': 0, + 'radioModes': list([ + True, + True, + True, + False, + False, + ]), + 'zb_channel': 0, + 'zb_flash_size': 704, + 'zb_hw': 'CC2652P7', + 'zb_ram_size': 152, + 'zb_type': 0, + 'zb_version': '20240314', + }), + ]), 'ram_total': 296, 'sw_version': 'v2.3.6', 'wifi_mode': 0, From 5dd147e83b3f02e7da75c33513760967b51850b9 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 0019/1435] 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 76570b51443bb0efb01b91e8cc9f2d2157f00a4a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:47:33 +0100 Subject: [PATCH 0020/1435] Remove stale translation string in HomeWizard (#136917) Remove stale translation in HomeWizard --- homeassistant/components/homewizard/strings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 806dbf6e083..02b18d5fa4e 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -174,8 +174,7 @@ } }, "error": { - "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]" } } } From 1c4ddb36d5586052d13f6fb515ffb006a32e7ac5 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 14:16:51 +0100 Subject: [PATCH 0021/1435] 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 bab616fa61a8a94d6144776a32e0c6d444f702a7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 15:25:16 +0100 Subject: [PATCH 0022/1435] 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 232e99b62ed388bb36b81ff56db43f81b878d606 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:22 +0100 Subject: [PATCH 0023/1435] 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 773375e7b0d5cb3c197ca2c318c2f67bb9d10631 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:39 +0100 Subject: [PATCH 0024/1435] 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 d148bd9b0cf128c74b971a25012c0363ee63ddbc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 16:33:59 +0100 Subject: [PATCH 0025/1435] 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 8db6a6cf176122746901414e7638e94be9fe62c9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Jan 2025 16:47:09 +0100 Subject: [PATCH 0026/1435] 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 6dd2d46328391689fade057dd5a6c09e24ed75e7 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 0027/1435] 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 63af407f8fb5f1e3d748a2e5d1cb0e8134a3a501 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 17:08:35 +0100 Subject: [PATCH 0028/1435] 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 ec53b08e0907177091edb7c5a7aa6f746e520171 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 17:32:01 +0100 Subject: [PATCH 0029/1435] 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 eca93f1f4e318a787d1d7f18bd9a0b6913c45c72 Mon Sep 17 00:00:00 2001 From: moritzthecat Date: Thu, 30 Jan 2025 17:33:41 +0100 Subject: [PATCH 0030/1435] Add DS2450 to onewire integration (#136882) * add DS2450 to onewire integration * added tests for DS2450 in const.py * Update homeassistant/components/onewire/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * spelling change voltage -> Voltage * use translation key * tests run after en.json edited * Update homeassistant/components/onewire/strings.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * naming convention adapted * Update homeassistant/components/onewire/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * adatpt owfs namings to HA namings. volt -> voltage * Apply suggestions from code review --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/onewire/const.py | 2 + homeassistant/components/onewire/sensor.py | 28 ++ homeassistant/components/onewire/strings.json | 6 + tests/components/onewire/const.py | 13 + .../onewire/snapshots/test_init.ambr | 32 ++ .../onewire/snapshots/test_sensor.ambr | 424 ++++++++++++++++++ 6 files changed, 505 insertions(+) diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 2ab44c47892..57cdd8c483c 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -10,6 +10,7 @@ DOMAIN = "onewire" DEVICE_KEYS_0_3 = range(4) DEVICE_KEYS_0_7 = range(8) DEVICE_KEYS_A_B = ("A", "B") +DEVICE_KEYS_A_D = ("A", "B", "C", "D") DEVICE_SUPPORT = { "05": (), @@ -17,6 +18,7 @@ DEVICE_SUPPORT = { "12": (), "1D": (), "1F": (), + "20": (), "22": (), "26": (), "28": (), diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 1c4047abf0a..04141f87847 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -33,6 +33,7 @@ from homeassistant.helpers.typing import StateType from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, + DEVICE_KEYS_A_D, OPTION_ENTRY_DEVICE_OPTIONS, OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, @@ -108,6 +109,33 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + "20": tuple( + [ + OneWireSensorEntityDescription( + key=f"latestvolt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="latest_voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + + [ + OneWireSensorEntityDescription( + key=f"volt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), "26": ( SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 8f46369a70b..46f41503d97 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -74,12 +74,18 @@ "humidity_raw": { "name": "Raw humidity" }, + "latest_voltage_id": { + "name": "Latest voltage {id}" + }, "moisture_id": { "name": "Moisture {id}" }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" }, + "voltage_id": { + "name": "Voltage {id}" + }, "voltage_vad": { "name": "VAD voltage" }, diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 4c05442eadc..370bcc871c6 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -65,6 +65,19 @@ MOCK_OWPROXY_DEVICES = { }, }, }, + "20.111111111111": { + ATTR_INJECT_READS: { + "/type": [b"DS2450"], + "/volt.A": [b" 1.1"], + "/volt.B": [b" 2.2"], + "/volt.C": [b" 3.3"], + "/volt.D": [b" 4.4"], + "/latestvolt.A": [b" 1.11"], + "/latestvolt.B": [b" 2.22"], + "/latestvolt.C": [b" 3.33"], + "/latestvolt.D": [b" 4.44"], + } + }, "22.111111111111": { ATTR_INJECT_READS: { "/type": [b"DS1822"], diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 159f3acea42..ee5d6d99158 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -159,6 +159,38 @@ 'via_device_id': None, }) # --- +# name: test_registry[20.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '20.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2450', + 'model_id': 'DS2450', + 'name': '20.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_registry[22.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 1b8484b27a4..b963e29d160 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -260,6 +260,430 @@ 'state': '248125', }) # --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.A', + 'friendly_name': '20.111111111111 Latest voltage A', + 'raw_value': 1.11, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.B', + 'friendly_name': '20.111111111111 Latest voltage B', + 'raw_value': 2.22, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.22', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.C', + 'friendly_name': '20.111111111111 Latest voltage C', + 'raw_value': 3.33, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.33', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.D', + 'friendly_name': '20.111111111111 Latest voltage D', + 'raw_value': 4.44, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.44', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.A', + 'friendly_name': '20.111111111111 Voltage A', + 'raw_value': 1.1, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.B', + 'friendly_name': '20.111111111111 Voltage B', + 'raw_value': 2.2, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.C', + 'friendly_name': '20.111111111111 Voltage C', + 'raw_value': 3.3, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.D', + 'friendly_name': '20.111111111111 Voltage D', + 'raw_value': 4.4, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- # name: test_sensors[sensor.22_111111111111_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f501b55aedbd1830475d815ad5c0fe394a9ab598 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 17:43:48 +0100 Subject: [PATCH 0031/1435] 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 3dc52774fc250e6437d54e84968b72c8377b837a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 09:15:13 -0800 Subject: [PATCH 0032/1435] 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 c3b0bc3e0db5f0faa0914eeca92ebe14ec4d98c6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 18:15:54 +0100 Subject: [PATCH 0033/1435] 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 6858f2a3d2deb6facce4815d514426dfb68e3e9e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 18:38:11 +0100 Subject: [PATCH 0034/1435] 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 5cea3cd444e..cdc710bc3c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==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 a6c6271c39a..ce31cb1dbc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From cf737356fd33bd25bb286b54cb8284eb7f0c9759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 12:55:14 -0600 Subject: [PATCH 0035/1435] 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 31aeb180b8c..edc039286d7 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 cdc710bc3c1..02091e9ec2a 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 ce31cb1dbc1..11905283d4d 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 b12598d9633e16af9d2330b40db304dec13b2874 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 13:38:27 -0600 Subject: [PATCH 0036/1435] 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 edc039286d7..74e3d51a222 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 acb3f4ed78720b84b40a9c79a5763bad8c1afe54 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:03:47 +0100 Subject: [PATCH 0037/1435] Add software version to onewire device info (#136934) --- .../components/onewire/onewirehub.py | 3 ++ tests/components/onewire/__init__.py | 4 +- .../onewire/snapshots/test_diagnostics.ambr | 1 + .../onewire/snapshots/test_init.ambr | 44 +++++++++---------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index a8d8dd06034..d65d7a90950 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -58,6 +58,7 @@ class OneWireHub: owproxy: protocol._Proxy devices: list[OWDeviceDescription] + _version: str def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" @@ -73,6 +74,7 @@ class OneWireHub: port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) self.owproxy = protocol.proxy(host, port) + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() self.devices = _discover_devices(self.owproxy) async def initialize(self) -> None: @@ -85,6 +87,7 @@ class OneWireHub: """Populate the device registry.""" device_registry = dr.async_get(self._hass) for device in devices: + device.device_info["sw_version"] = self._version device_registry.async_get_or_create( config_entry_id=self._config_entry.entry_id, **device.device_info, diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 9c025fe33af..595b660b722 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -13,7 +13,9 @@ from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> None: """Set up mock for owproxy.""" dir_side_effect: dict[str, list] = {} - read_side_effect: dict[str, list] = {} + read_side_effect: dict[str, list] = { + "/system/configuration/version": [b"3.2"], + } # Setup directory listing dir_side_effect["/"] = [[f"/{device_id}/" for device_id in device_ids]] diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr index 6c5631331ca..c60d0a9748b 100644 --- a/tests/components/onewire/snapshots/test_diagnostics.ambr +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -15,6 +15,7 @@ 'model_id': 'HB_HUB', 'name': 'EF.111111111113', 'serial_number': '111111111113', + 'sw_version': '3.2', }), 'family': 'EF', 'id': 'EF.111111111113', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index ee5d6d99158..5666dab6383 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -27,7 +27,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -59,7 +59,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -91,7 +91,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -123,7 +123,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': , }) # --- @@ -155,7 +155,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -187,7 +187,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -219,7 +219,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -251,7 +251,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -283,7 +283,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -315,7 +315,7 @@ 'primary_config_entry': , 'serial_number': '222222222222', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -347,7 +347,7 @@ 'primary_config_entry': , 'serial_number': '222222222223', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -379,7 +379,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -411,7 +411,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -443,7 +443,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -475,7 +475,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -507,7 +507,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -539,7 +539,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -571,7 +571,7 @@ 'primary_config_entry': , 'serial_number': '222222222222', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -603,7 +603,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -635,7 +635,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -667,7 +667,7 @@ 'primary_config_entry': , 'serial_number': '111111111112', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -699,7 +699,7 @@ 'primary_config_entry': , 'serial_number': '111111111113', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- From ea496290c268c9e190e4b1a4fcfeebe74fc2689f Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 30 Jan 2025 21:59:00 +0100 Subject: [PATCH 0038/1435] 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 02091e9ec2a..f3c22e1b215 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 11905283d4d..f481aea392a 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 00f8afe33280617c6859d61f5ce23bc570706399 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 30 Jan 2025 16:01:24 -0600 Subject: [PATCH 0039/1435] 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 f93b1cc950415b13daddb3281e65740cf9ef9911 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 30 Jan 2025 23:03:56 +0100 Subject: [PATCH 0040/1435] Make assist_satellite action descriptions consistent (#136955) - use third-person singular for descriptive language, following HA standards - use "a satellite" in both descriptions to match - use sentence-casing for "Start conversation" action name --- homeassistant/components/assist_satellite/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index e83f4666b5d..fa2dc984ab7 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -14,7 +14,7 @@ "services": { "announce": { "name": "Announce", - "description": "Let the satellite announce a message.", + "description": "Lets a satellite announce a message.", "fields": { "message": { "name": "Message", @@ -27,8 +27,8 @@ } }, "start_conversation": { - "name": "Start Conversation", - "description": "Start a conversation from a satellite.", + "name": "Start conversation", + "description": "Starts a conversation from a satellite.", "fields": { "start_message": { "name": "Message", From 6c93d6a2d0ec982dc10f2ea4ef4cc939c8294635 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 22:59:03 -0800 Subject: [PATCH 0041/1435] 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 4613087e864ae89f98b8a4132b51df62be18adaf Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 31 Jan 2025 09:23:03 +0200 Subject: [PATCH 0042/1435] Add serial number to LG webOS TV device info (#136968) --- homeassistant/components/webostv/media_player.py | 3 +++ tests/components/webostv/conftest.py | 2 +- tests/components/webostv/snapshots/test_diagnostics.ambr | 1 + tests/components/webostv/snapshots/test_media_player.ambr | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c8b871b3bf2..076b6caad24 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -284,6 +284,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if model := self._client.system_info.get("modelName"): self._attr_device_info["model"] = model + if serial_number := self._client.system_info.get("serialNumber"): + self._attr_device_info["serial_number"] = serial_number + self._attr_extra_state_attributes = {} if self._client.sound_output is not None or self.state != MediaPlayerState.OFF: self._attr_extra_state_attributes = { diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index bf007f5b936..c6594746cc5 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -42,7 +42,7 @@ def client_fixture(): client = mock_client_class.return_value client.hello_info = {"deviceUUID": FAKE_UUID} client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": TV_MODEL} + client.system_info = {"modelName": TV_MODEL, "serialNumber": "1234567890"} client.client_key = CLIENT_KEY client.apps = MOCK_APPS client.inputs = MOCK_INPUTS diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr index a9bd6e91ee0..07ee50af1f8 100644 --- a/tests/components/webostv/snapshots/test_diagnostics.ambr +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -41,6 +41,7 @@ 'sound_output': 'speaker', 'system_info': dict({ 'modelName': 'MODEL', + 'serialNumber': '1234567890', }), }), 'entry': dict({ diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 35a703cc109..23f45a0f325 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -61,7 +61,7 @@ 'name': 'LG webOS TV MODEL', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '1234567890', 'suggested_area': None, 'sw_version': 'major.minor', 'via_device_id': None, From 4d4e11a0eb90639ec91a9d927e89b7a834becec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 31 Jan 2025 08:26:57 +0100 Subject: [PATCH 0043/1435] Fetch all programs instead of only the available ones at Home Connect (#136949) Fetch all programs instead of only the available ones --- .../components/home_connect/coordinator.py | 8 ++--- .../components/home_connect/switch.py | 4 +-- tests/components/home_connect/conftest.py | 19 +++++------- ...{programs-available.json => programs.json} | 0 tests/components/home_connect/test_select.py | 30 +++++++++---------- tests/components/home_connect/test_switch.py | 23 ++++++-------- 6 files changed, 35 insertions(+), 49 deletions(-) rename tests/components/home_connect/fixtures/{programs-available.json => programs.json} (100%) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 2c70d74150e..29bd961220e 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -25,7 +25,7 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -48,7 +48,7 @@ class HomeConnectApplianceData: events: dict[EventKey, Event] = field(default_factory=dict) info: HomeAppliance - programs: list[EnumerateAvailableProgram] = field(default_factory=list) + programs: list[EnumerateProgram] = field(default_factory=list) settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -243,9 +243,7 @@ class HomeConnectCoordinator( ): try: appliance_data.programs.extend( - ( - await self.client.get_available_programs(appliance.ha_id) - ).programs + (await self.client.get_all_programs(appliance.ha_id)).programs ) except HomeConnectError as error: _LOGGER.debug( diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index c3a0858e0bb..521252ccc2f 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -5,7 +5,7 @@ from typing import Any, cast from aiohomeconnect.model import EventKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -184,7 +184,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): self, coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, - program: EnumerateAvailableProgram, + program: EnumerateProgram, ) -> None: """Initialize the entity.""" desc = " ".join(["Program", program.key.split(".")[-1]]) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index af039f04c03..ae98c69d242 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( - ArrayOfAvailablePrograms, ArrayOfEvents, ArrayOfHomeAppliances, + ArrayOfPrograms, ArrayOfSettings, ArrayOfStatus, Event, @@ -37,9 +37,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( load_json_object_fixture("home_connect/appliances.json")["data"] ) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( - "home_connect/programs-available.json" -) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") MOCK_STATUS = ArrayOfStatus.from_dict( load_json_object_fixture("home_connect/status.json")["data"] @@ -219,8 +217,8 @@ def _get_set_key_value_side_effect( return set_key_value_side_effect -async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: - """Get available programs.""" +async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" appliance_type = next( appliance for appliance in MOCK_APPLIANCES.homeappliances @@ -229,7 +227,7 @@ async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePro if appliance_type not in MOCK_PROGRAMS: raise HomeConnectApiError("error.key", "error description") - return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + return ArrayOfPrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: @@ -290,9 +288,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ) mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) - mock.get_available_programs = AsyncMock( - side_effect=_get_available_programs_side_effect - ) + mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() mock.side_effect = mock @@ -323,7 +319,6 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.start_program = AsyncMock(side_effect=exception) mock.stop_program = AsyncMock(side_effect=exception) - mock.get_available_programs = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) @@ -331,7 +326,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_settings = AsyncMock(side_effect=exception) mock.get_setting = AsyncMock(side_effect=exception) mock.get_status = AsyncMock(side_effect=exception) - mock.get_available_programs = AsyncMock(side_effect=exception) + mock.get_all_programs = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) return mock diff --git a/tests/components/home_connect/fixtures/programs-available.json b/tests/components/home_connect/fixtures/programs.json similarity index 100% rename from tests/components/home_connect/fixtures/programs-available.json rename to tests/components/home_connect/fixtures/programs.json diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 6ebd37266cd..a0cdd15bf31 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -4,8 +4,8 @@ from collections.abc import Awaitable, Callable from unittest.mock import MagicMock from aiohomeconnect.model import ( - ArrayOfAvailablePrograms, ArrayOfEvents, + ArrayOfPrograms, Event, EventKey, EventMessage, @@ -13,7 +13,7 @@ from aiohomeconnect.model import ( ProgramKey, ) from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram import pytest from homeassistant.components.select import ( @@ -61,14 +61,14 @@ async def test_filter_unknown_programs( entity_registry: er.EntityRegistry, ) -> None: """Test select that only known programs are shown.""" - client.get_available_programs.side_effect = None - client.get_available_programs.return_value = ArrayOfAvailablePrograms( + client.get_all_programs.side_effect = None + client.get_all_programs.return_value = ArrayOfPrograms( [ - EnumerateAvailableProgram( + EnumerateProgram( key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, ), - EnumerateAvailableProgram( + EnumerateProgram( key=ProgramKey.UNKNOWN, raw_key="an unknown program", ), @@ -202,16 +202,14 @@ async def test_select_exception_handling( client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_available_programs.side_effect = None - client_with_exception.get_available_programs.return_value = ( - ArrayOfAvailablePrograms( - [ - EnumerateAvailableProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] ) assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 10d393423be..4d6b59eddd9 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -15,10 +15,7 @@ from aiohomeconnect.model import ( ) from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.event import ArrayOfEvents, EventType -from aiohomeconnect.model.program import ( - ArrayOfAvailablePrograms, - EnumerateAvailableProgram, -) +from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -250,16 +247,14 @@ async def test_switch_exception_handling( client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_available_programs.side_effect = None - client_with_exception.get_available_programs.return_value = ( - ArrayOfAvailablePrograms( - [ - EnumerateAvailableProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] ) client_with_exception.get_settings.side_effect = None client_with_exception.get_settings.return_value = ArrayOfSettings( From 99e307fe5a752dc4c732b90e24d8b4c81b61b231 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 23:33:58 -0800 Subject: [PATCH 0044/1435] 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 f3c22e1b215..dc5dc04420f 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 f481aea392a..8707b3ff044 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 fc979cd564ee2d5fd27e05b32fa6f11b343ee4d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 01:34:39 -0600 Subject: [PATCH 0045/1435] 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 dc5dc04420f..679c496e5a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,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 8707b3ff044..09478cb6447 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,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 270108e8e4c9484fae94bbc10f2cb895a956596c Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Fri, 31 Jan 2025 01:36:06 -0800 Subject: [PATCH 0046/1435] 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 679c496e5a5..a60f19c6ef3 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 09478cb6447..9c461aabfe8 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 f1c720606f1fa0fa71c544da0b9124be8430315b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 10:38:30 +0100 Subject: [PATCH 0047/1435] 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 ab5583ed40a8c0ebf03c4c051dd67d3c2fd777e3 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 31 Jan 2025 20:55:42 +1100 Subject: [PATCH 0048/1435] 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 3fb70316daadb48bcdfc6d1d71895006276f8458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 31 Jan 2025 10:10:57 +0000 Subject: [PATCH 0049/1435] Fix error messaging for cascading service calls (#136966) --- homeassistant/components/websocket_api/commands.py | 8 ++++---- tests/components/websocket_api/test_commands.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cfa132b71eb..4a360b4a43c 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -275,10 +275,10 @@ async def handle_call_service( translation_domain=const.DOMAIN, translation_key="child_service_not_found", translation_placeholders={ - "domain": err.domain, - "service": err.service, - "child_domain": msg["domain"], - "child_service": msg["service"], + "domain": msg["domain"], + "service": msg["service"], + "child_domain": err.domain, + "child_service": err.service, }, ) except vol.Invalid as err: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 22e839d84e4..2ddb5c628c7 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -460,10 +460,10 @@ async def test_call_service_child_not_found( "domain_test.test_service which was not found." ) assert msg["error"]["translation_placeholders"] == { - "domain": "non", - "service": "existing", - "child_domain": "domain_test", - "child_service": "test_service", + "domain": "domain_test", + "service": "test_service", + "child_domain": "non", + "child_service": "existing", } assert msg["error"]["translation_key"] == "child_service_not_found" assert msg["error"]["translation_domain"] == "websocket_api" From 230e101ee4b4fdc691de4cd3911d742ff86e57fe Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 11:23:33 +0100 Subject: [PATCH 0050/1435] 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 e57832705428ad8a7ffeef0c9706f2f6aeee57cb Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 31 Jan 2025 11:46:12 +0100 Subject: [PATCH 0051/1435] Add more Homee cover tests (#136568) --- tests/components/homee/__init__.py | 40 ++- tests/components/homee/conftest.py | 2 + tests/components/homee/fixtures/cover3.json | 101 ------ tests/components/homee/fixtures/cover4.json | 101 ------ ...r1.json => cover_with_position_slats.json} | 8 +- ...r2.json => cover_with_slats_position.json} | 100 ++---- .../fixtures/cover_without_position.json | 48 +++ tests/components/homee/test_cover.py | 329 ++++++++++++------ 8 files changed, 358 insertions(+), 371 deletions(-) delete mode 100644 tests/components/homee/fixtures/cover3.json delete mode 100644 tests/components/homee/fixtures/cover4.json rename tests/components/homee/fixtures/{cover1.json => cover_with_position_slats.json} (95%) rename tests/components/homee/fixtures/{cover2.json => cover_with_slats_position.json} (50%) create mode 100644 tests/components/homee/fixtures/cover_without_position.json diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index 95fc6099269..a5f8ae00d1e 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -1,8 +1,14 @@ """Tests for the homee component.""" +from typing import Any +from unittest.mock import AsyncMock + +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.homee.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: @@ -11,3 +17,35 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def build_mock_node(file: str) -> AsyncMock: + """Build a mocked Homee node from a json representation.""" + json_node = load_json_object_fixture(file, DOMAIN) + mock_node = AsyncMock(spec=HomeeNode) + + def get_attributes(attributes: list[Any]) -> list[AsyncMock]: + mock_attributes: list[AsyncMock] = [] + for attribute in attributes: + att = AsyncMock(spec=HomeeAttribute) + for key, value in attribute.items(): + setattr(att, key, value) + att.is_reversed = False + att.get_value = ( + lambda att=att: att.data if att.unit == "text" else att.current_value + ) + mock_attributes.append(att) + return mock_attributes + + for key, value in json_node.items(): + if key != "attributes": + setattr(mock_node, key, value) + + mock_node.attributes = get_attributes(json_node["attributes"]) + + def attribute_by_type(type, instance=0) -> HomeeAttribute | None: + return {attr.type: attr for attr in mock_node.attributes}.get(type) + + mock_node.get_attribute_by_type = attribute_by_type + + return mock_node diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index fb94ba0bbcc..5a3234e896b 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -61,6 +61,8 @@ def mock_homee() -> Generator[AsyncMock]: homee.settings = MagicMock() homee.settings.uid = HOMEE_ID homee.settings.homee_name = HOMEE_NAME + homee.settings.version = "1.2.3" + homee.settings.mac_address = "00:05:55:11:ee:cc" homee.reconnect_interval = 10 homee.connected = True diff --git a/tests/components/homee/fixtures/cover3.json b/tests/components/homee/fixtures/cover3.json deleted file mode 100644 index 0d3d5ea57e2..00000000000 --- a/tests/components/homee/fixtures/cover3.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "id": 3, - "name": "Test%20Cover", - "profile": 2002, - "image": "default", - "favorite": 0, - "order": 4, - "protocol": 23, - "routing": 0, - "state": 1, - "state_changed": 1687175681, - "added": 1672086680, - "history": 1, - "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, - "phonetic_name": "", - "owner": 2, - "security": 0, - "attributes": [ - { - "id": 1, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 3.0, - "target_value": 0.0, - "last_value": 1.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 75.0, - "target_value": 0.0, - "last_value": 100.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 3, - "instance": 0, - "minimum": -45, - "maximum": 90, - "current_value": 56.0, - "target_value": 56.0, - "last_value": 0.0, - "unit": "%C2%B0", - "step_value": 1.0, - "editable": 1, - "type": 113, - "state": 1, - "last_changed": 1678284920, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"] - } - } - ] -} diff --git a/tests/components/homee/fixtures/cover4.json b/tests/components/homee/fixtures/cover4.json deleted file mode 100644 index a3de555794a..00000000000 --- a/tests/components/homee/fixtures/cover4.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "id": 3, - "name": "Test%20Cover", - "profile": 2002, - "image": "default", - "favorite": 0, - "order": 4, - "protocol": 23, - "routing": 0, - "state": 1, - "state_changed": 1687175681, - "added": 1672086680, - "history": 1, - "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, - "phonetic_name": "", - "owner": 2, - "security": 0, - "attributes": [ - { - "id": 1, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 4.0, - "target_value": 1.0, - "last_value": 0.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 25.0, - "target_value": 100.0, - "last_value": 0.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 3, - "instance": 0, - "minimum": -45, - "maximum": 90, - "current_value": -11.0, - "target_value": 0.0, - "last_value": -45.0, - "unit": "%C2%B0", - "step_value": 1.0, - "editable": 1, - "type": 113, - "state": 1, - "last_changed": 1678284920, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"] - } - } - ] -} diff --git a/tests/components/homee/fixtures/cover1.json b/tests/components/homee/fixtures/cover_with_position_slats.json similarity index 95% rename from tests/components/homee/fixtures/cover1.json rename to tests/components/homee/fixtures/cover_with_position_slats.json index 8fedfb19d4f..8fd0d6f44fe 100644 --- a/tests/components/homee/fixtures/cover1.json +++ b/tests/components/homee/fixtures/cover_with_position_slats.json @@ -1,6 +1,6 @@ { "id": 3, - "name": "Test%20Cover", + "name": "Test Cover", "profile": 2002, "image": "default", "favorite": 0, @@ -27,7 +27,7 @@ "current_value": 1.0, "target_value": 1.0, "last_value": 4.0, - "unit": "n%2Fa", + "unit": "n/a", "step_value": 1.0, "editable": 1, "type": 135, @@ -53,7 +53,7 @@ "current_value": 0.0, "target_value": 0.0, "last_value": 0.0, - "unit": "%25", + "unit": "%", "step_value": 0.5, "editable": 1, "type": 15, @@ -82,7 +82,7 @@ "current_value": -45.0, "target_value": 0.0, "last_value": -45.0, - "unit": "%C2%B0", + "unit": "°", "step_value": 1.0, "editable": 1, "type": 113, diff --git a/tests/components/homee/fixtures/cover2.json b/tests/components/homee/fixtures/cover_with_slats_position.json similarity index 50% rename from tests/components/homee/fixtures/cover2.json rename to tests/components/homee/fixtures/cover_with_slats_position.json index b53c3d49b62..4b6eb466a85 100644 --- a/tests/components/homee/fixtures/cover2.json +++ b/tests/components/homee/fixtures/cover_with_slats_position.json @@ -1,19 +1,19 @@ { "id": 1, - "name": "Test%20Cover", + "name": "Test Slats", "profile": 2002, "image": "default", "favorite": 0, - "order": 4, + "order": 1, "protocol": 23, "routing": 0, "state": 1, - "state_changed": 1687175681, - "added": 1672086680, + "state_changed": 1676901608, + "added": 1672148537, "history": 1, "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, + "note": "", + "services": 70, "phonetic_name": "", "owner": 2, "security": 0, @@ -22,67 +22,12 @@ "id": 1, "node_id": 1, "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 1.0, - "target_value": 1.0, - "last_value": 0.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 1, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 100.0, - "target_value": 0.0, - "last_value": 0.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 1, - "instance": 0, "minimum": -45, "maximum": 90, - "current_value": 90.0, - "target_value": 0.0, - "last_value": -45.0, - "unit": "%C2%B0", + "current_value": 1.0, + "target_value": 1.0, + "last_value": -21.0, + "unit": "°", "step_value": 1.0, "editable": 1, "type": 113, @@ -96,6 +41,31 @@ "options": { "automations": ["step"] } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 337, + "state": 1, + "last_changed": 1678284911, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [72] + } } ] } diff --git a/tests/components/homee/fixtures/cover_without_position.json b/tests/components/homee/fixtures/cover_without_position.json new file mode 100644 index 00000000000..e2bc6c7a38d --- /dev/null +++ b/tests/components/homee/fixtures/cover_without_position.json @@ -0,0 +1,48 @@ +{ + "id": 3, + "name": "Test Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + } + ] +} diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index a7feaa10b66..d52f3fa3164 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -1,97 +1,38 @@ """Test homee covers.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock -from pyHomee import HomeeNode - -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverState -from homeassistant.components.homee.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, +) from homeassistant.core import HomeAssistant -from . import setup_integration +from . import build_mock_node, setup_integration -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry -async def test_cover_open( - hass: HomeAssistant, mock_homee: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test an open cover.""" - # Cover open, tilt open. - cover_json = load_json_object_fixture("cover1.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.OPEN - - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("supported_features") == 143 - assert attributes.get("current_position") == 100 - assert attributes.get("current_tilt_position") == 100 - - -async def test_cover_closed( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test a closed cover.""" - # Cover closed, tilt closed. - cover_json = load_json_object_fixture("cover2.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.CLOSED - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 0 - assert attributes.get("current_tilt_position") == 0 - - -async def test_cover_opening( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test an opening cover.""" - # opening, 75% homee / 25% HA - cover_json = load_json_object_fixture("cover3.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.OPENING - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 25 - assert attributes.get("current_tilt_position") == 25 - - -async def test_cover_closing( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test a closing cover.""" - # closing, 25% homee / 75% HA - cover_json = load_json_object_fixture("cover4.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.CLOSING - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 75 - assert attributes.get("current_tilt_position") == 74 - - -async def test_open_cover( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +async def test_open_close_stop_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test opening the cover.""" - # Cover closed, tilt closed. - cover_json = load_json_object_fixture("cover2.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] await setup_integration(hass, mock_config_entry) @@ -101,24 +42,214 @@ async def test_open_cover( {ATTR_ENTITY_ID: "cover.test_cover"}, blocking=True, ) - mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 0) - - -async def test_close_cover( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test opening the cover.""" - # Cover open, tilt open. - cover_json = load_json_object_fixture("cover1.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.test_cover"}, blocking=True, ) - mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 1) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls): + assert call[0] == (mock_homee.nodes[0].id, 1, index) + + +async def test_set_cover_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the cover position.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [0, 100, 50] + for call in calls: + assert call[0] == (1, 2, positions.pop(0)) + + +async def test_close_open_slats( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test closing and opening slats.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("cover.test_slats").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls, start=1): + assert call[0] == (mock_homee.nodes[0].id, 2, index) + + +async def test_set_slat_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting slats position.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90 on this device. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [-45, 90, 22.5] + for call in calls: + assert call[0] == (1, 1, positions.pop(0)) + + +async def test_cover_positions( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test an open cover.""" + # Cover open, tilt open. + # mock_homee.nodes = [cover] + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_TILT_POSITION + ) + assert attributes.get("current_position") == 100 + assert attributes.get("current_tilt_position") == 100 + + cover.attributes[0].current_value = 1 + cover.attributes[1].current_value = 100 + cover.attributes[2].current_value = 90 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 0 + assert attributes.get("current_tilt_position") == 0 + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + + cover.attributes[0].current_value = 3 + cover.attributes[1].current_value = 75 + cover.attributes[2].current_value = 56 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.OPENING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 25 + assert attributes.get("current_tilt_position") == 25 + + cover.attributes[0].current_value = 4 + cover.attributes[1].current_value = 25 + cover.attributes[2].current_value = -11 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 75 + assert attributes.get("current_tilt_position") == 74 + + +async def test_reversed_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a cover with inverted UP_DOWN attribute without position.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + cover.attributes[0].is_reversed = True + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + cover.attributes[0].current_value = 0 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED From e512ad7a81f559c088d0789f3024fd4cd22396c5 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 12:10:44 +0100 Subject: [PATCH 0052/1435] 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 010cad08c05988dbcedff9232fb4f76d4ce1f691 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Fri, 31 Jan 2025 12:12:07 +0100 Subject: [PATCH 0053/1435] Add tariff sensor and peak sensors (#136919) --- homeassistant/components/youless/sensor.py | 34 +++- homeassistant/components/youless/strings.json | 9 + tests/components/youless/__init__.py | 5 + tests/components/youless/fixtures/device.json | 2 +- tests/components/youless/fixtures/phase.json | 15 ++ .../youless/snapshots/test_sensor.ambr | 176 +++++++++++++++++- tests/components/youless/test_init.py | 2 +- 7 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 tests/components/youless/fixtures/phase.json diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 3afb215ed5f..db8244c0b06 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -36,7 +36,7 @@ class YouLessSensorEntityDescription(SensorEntityDescription): """Describes a YouLess sensor entity.""" device_group: str - value_func: Callable[[YoulessAPI], float | None] + value_func: Callable[[YoulessAPI], float | None | str] SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( @@ -212,6 +212,38 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( lambda device: device.phase3.current.value if device.phase1 else None ), ), + YouLessSensorEntityDescription( + key="tariff", + device_group="power", + translation_key="active_tariff", + device_class=SensorDeviceClass.ENUM, + options=["1", "2"], + value_func=( + lambda device: str(device.current_tariff) if device.current_tariff else None + ), + ), + YouLessSensorEntityDescription( + key="average_peak", + device_group="power", + translation_key="average_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.average_power.value if device.average_power else None + ), + ), + YouLessSensorEntityDescription( + key="month_peak", + device_group="power", + translation_key="month_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.peak_power.value if device.peak_power else None + ), + ), YouLessSensorEntityDescription( key="delivery_low", device_group="delivery", diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 8a3f6cb5d8b..c735e2b2ff2 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -52,6 +52,9 @@ "active_current_phase_a": { "name": "Current phase {phase}" }, + "active_tariff": { + "name": "Tariff" + }, "total_energy_import_tariff_kwh": { "name": "Energy import tariff {tariff}" }, @@ -66,6 +69,12 @@ }, "active_s0_w": { "name": "Current usage" + }, + "average_peak": { + "name": "Average peak" + }, + "month_peak": { + "name": "Month peak" } } } diff --git a/tests/components/youless/__init__.py b/tests/components/youless/__init__.py index 8770a7e2dc8..03db24cb7f7 100644 --- a/tests/components/youless/__init__.py +++ b/tests/components/youless/__init__.py @@ -25,6 +25,11 @@ async def init_component(hass: HomeAssistant) -> MockConfigEntry: json=load_json_array_fixture("enologic.json", youless.DOMAIN), headers={"Content-Type": "application/json"}, ) + mock.get( + "http://1.1.1.1/f", + json=load_json_object_fixture("phase.json", youless.DOMAIN), + headers={"Content-Type": "application/json"}, + ) entry = MockConfigEntry( domain=youless.DOMAIN, diff --git a/tests/components/youless/fixtures/device.json b/tests/components/youless/fixtures/device.json index 7d089851923..82d07dba739 100644 --- a/tests/components/youless/fixtures/device.json +++ b/tests/components/youless/fixtures/device.json @@ -1,5 +1,5 @@ { "model": "LS120", - "fw": "1.4.2-EL", + "fw": "1.5.1-EL", "mac": "de2:2d2:3d23" } diff --git a/tests/components/youless/fixtures/phase.json b/tests/components/youless/fixtures/phase.json new file mode 100644 index 00000000000..8a5aa3215ef --- /dev/null +++ b/tests/components/youless/fixtures/phase.json @@ -0,0 +1,15 @@ +{ + "tr": 1, + "i1": 0.123, + "v1": 240, + "l1": 462, + "v2": 240, + "l2": 230, + "i2": 0.123, + "v3": 240, + "l3": 230, + "i3": 0.123, + "pp": 1200, + "pts": 2501301621, + "pa": 400 +} diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 0647d854d2a..9e79b5b9b5e 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -152,6 +152,57 @@ 'state': '1624.264', }) # --- +# name: test_sensors[sensor.power_meter_average_peak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_average_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_peak', + 'unique_id': 'youless_localhost_average_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_average_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Average peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_average_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '400', + }) +# --- # name: test_sensors[sensor.power_meter_current_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -200,7 +251,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_phase_2-entry] @@ -251,7 +302,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_phase_3-entry] @@ -302,7 +353,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_power_usage-entry] @@ -458,6 +509,57 @@ 'state': '4490.631', }) # --- +# name: test_sensors[sensor.power_meter_month_peak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_month_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Month peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'month_peak', + 'unique_id': 'youless_localhost_month_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_month_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Month peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_month_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200', + }) +# --- # name: test_sensors[sensor.power_meter_power_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -506,7 +608,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '462', }) # --- # name: test_sensors[sensor.power_meter_power_phase_2-entry] @@ -557,7 +659,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230', }) # --- # name: test_sensors[sensor.power_meter_power_phase_3-entry] @@ -608,7 +710,63 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230', + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tariff', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'youless_localhost_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Power meter Tariff', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'sensor.power_meter_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', }) # --- # name: test_sensors[sensor.power_meter_total_energy_import-entry] @@ -710,7 +868,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.power_meter_voltage_phase_2-entry] @@ -761,7 +919,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.power_meter_voltage_phase_3-entry] @@ -812,7 +970,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.s0_meter_current_usage-entry] diff --git a/tests/components/youless/test_init.py b/tests/components/youless/test_init.py index 29db8c66af0..9f0956cea35 100644 --- a/tests/components/youless/test_init.py +++ b/tests/components/youless/test_init.py @@ -15,4 +15,4 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert await setup.async_setup_component(hass, youless.DOMAIN, {}) assert entry.state is ConfigEntryState.LOADED - assert len(hass.states.async_entity_ids()) == 19 + assert len(hass.states.async_entity_ids()) == 22 From a7903d344f2889dc95001ab259d45fce52218ccf 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 0054/1435] 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 a60f19c6ef3..8a579ca6ba0 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 9c461aabfe8..7612c8466d3 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 50f3d79fb21495d1f6d6d52b7dc858c7bfea7fb9 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 31 Jan 2025 11:29:23 +0000 Subject: [PATCH 0055/1435] Add post action to mastodon (#134788) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/mastodon/__init__.py | 24 +- homeassistant/components/mastodon/const.py | 7 + .../components/mastodon/coordinator.py | 15 ++ homeassistant/components/mastodon/icons.json | 5 + homeassistant/components/mastodon/notify.py | 68 +++-- .../components/mastodon/quality_scale.yaml | 8 +- homeassistant/components/mastodon/services.py | 142 ++++++++++ .../components/mastodon/services.yaml | 30 +++ .../components/mastodon/strings.json | 65 +++++ homeassistant/components/mastodon/utils.py | 11 + tests/components/mastodon/test_notify.py | 27 ++ tests/components/mastodon/test_services.py | 246 ++++++++++++++++++ 12 files changed, 607 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/mastodon/services.py create mode 100644 homeassistant/components/mastodon/services.yaml create mode 100644 tests/components/mastodon/test_services.py diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index f7f974ffbb0..2f713a97dfe 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass - from mastodon.Mastodon import Mastodon, MastodonError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -16,27 +13,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from .const import CONF_BASE_URL, DOMAIN, LOGGER -from .coordinator import MastodonCoordinator +from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData +from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] - -@dataclass -class MastodonData: - """Mastodon data type.""" - - client: Mastodon - instance: dict - account: dict - coordinator: MastodonCoordinator +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type MastodonConfigEntry = ConfigEntry[MastodonData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Mastodon component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index e0593d15d2c..b7e86eaad5a 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -19,3 +19,10 @@ ACCOUNT_USERNAME: Final = "username" ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count" ACCOUNT_FOLLOWING_COUNT: Final = "following_count" ACCOUNT_STATUSES_COUNT: Final = "statuses_count" + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_STATUS = "status" +ATTR_VISIBILITY = "visibility" +ATTR_CONTENT_WARNING = "content_warning" +ATTR_MEDIA_WARNING = "media_warning" +ATTR_MEDIA = "media" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index f1332a0ea43..4c6fe6b1c88 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -2,18 +2,33 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import Any from mastodon import Mastodon from mastodon.Mastodon import MastodonError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +@dataclass +class MastodonData: + """Mastodon data type.""" + + client: Mastodon + instance: dict + account: dict + coordinator: MastodonCoordinator + + +type MastodonConfigEntry = ConfigEntry[MastodonData] + + class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Mastodon data.""" diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index 082e27a64c2..e7272c2b6f8 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -11,5 +11,10 @@ "default": "mdi:message-text" } } + }, + "services": { + "post": { + "service": "mdi:message-text" + } } } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index dd76d44a02c..8e7e9dc1947 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -2,7 +2,6 @@ from __future__ import annotations -import mimetypes from typing import Any, cast from mastodon import Mastodon @@ -16,15 +15,21 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER +from .const import ( + ATTR_CONTENT_WARNING, + ATTR_MEDIA_WARNING, + CONF_BASE_URL, + DEFAULT_URL, + DOMAIN, +) +from .utils import get_media_type ATTR_MEDIA = "media" ATTR_TARGET = "target" -ATTR_MEDIA_WARNING = "media_warning" -ATTR_CONTENT_WARNING = "content_warning" PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( { @@ -67,6 +72,17 @@ class MastodonNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Toot a message, with media perhaps.""" + ir.create_issue( + self.hass, + DOMAIN, + "deprecated_notify_action_mastodon", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_notify_action", + ) + target = None if (target_list := kwargs.get(ATTR_TARGET)) is not None: target = cast(list[str], target_list)[0] @@ -82,8 +98,11 @@ class MastodonNotificationService(BaseNotificationService): media = data.get(ATTR_MEDIA) if media: if not self.hass.config.is_allowed_path(media): - LOGGER.warning("'%s' is not a whitelisted directory", media) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media}, + ) mediadata = self._upload_media(media) sensitive = data.get(ATTR_MEDIA_WARNING) @@ -93,34 +112,39 @@ class MastodonNotificationService(BaseNotificationService): try: self.client.status_post( message, - media_ids=mediadata["id"], - sensitive=sensitive, visibility=target, spoiler_text=content_warning, + media_ids=mediadata["id"], + sensitive=sensitive, ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + else: try: self.client.status_post( message, visibility=target, spoiler_text=content_warning ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err def _upload_media(self, media_path: Any = None) -> Any: """Upload media.""" with open(media_path, "rb"): - media_type = self._media_type(media_path) + media_type = get_media_type(media_path) try: mediadata = self.client.media_post(media_path, mime_type=media_type) - except MastodonAPIError: - LOGGER.error("Unable to upload image %s", media_path) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err return mediadata - - def _media_type(self, media_path: Any = None) -> Any: - """Get media Type.""" - (media_type, _) = mimetypes.guess_type(media_path) - - return media_type diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 86702095e95..43636ed6924 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -29,7 +29,7 @@ rules: action-exceptions: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -42,7 +42,7 @@ rules: parallel-updates: status: todo comment: | - Does not set parallel-updates on notify platform. + Awaiting legacy Notify deprecation. reauthentication-flow: status: todo comment: | @@ -50,7 +50,7 @@ rules: test-coverage: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. # Gold devices: done @@ -78,7 +78,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: status: todo diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py new file mode 100644 index 00000000000..ab3a89c0c4b --- /dev/null +++ b/homeassistant/components/mastodon/services.py @@ -0,0 +1,142 @@ +"""Define services for the Mastodon integration.""" + +from enum import StrEnum +from functools import partial +from typing import Any, cast + +from mastodon import Mastodon +from mastodon.Mastodon import MastodonAPIError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import MastodonConfigEntry +from .const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_MEDIA_WARNING, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, +) +from .utils import get_media_type + + +class StatusVisibility(StrEnum): + """StatusVisibility model.""" + + PUBLIC = "public" + UNLISTED = "unlisted" + PRIVATE = "private" + DIRECT = "direct" + + +SERVICE_POST = "post" +SERVICE_POST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_STATUS): str, + vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), + vol.Optional(ATTR_CONTENT_WARNING): str, + vol.Optional(ATTR_MEDIA): str, + vol.Optional(ATTR_MEDIA_WARNING): bool, + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MastodonConfigEntry: + """Get the Mastodon config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(MastodonConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Mastodon integration.""" + + async def async_post(call: ServiceCall) -> ServiceResponse: + """Post a status.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + + status = call.data[ATTR_STATUS] + + visibility: str | None = ( + StatusVisibility(call.data[ATTR_VISIBILITY]) + if ATTR_VISIBILITY in call.data + else None + ) + spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING) + media_path: str | None = call.data.get(ATTR_MEDIA) + media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING) + + await hass.async_add_executor_job( + partial( + _post, + client=client, + status=status, + visibility=visibility, + spoiler_text=spoiler_text, + media_path=media_path, + sensitive=media_warning, + ) + ) + + return None + + def _post(client: Mastodon, **kwargs: Any) -> None: + """Post to Mastodon.""" + + media_data: dict[str, Any] | None = None + + media_path = kwargs.get("media_path") + if media_path: + if not hass.config.is_allowed_path(media_path): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media_path}, + ) + + media_type = get_media_type(media_path) + try: + media_data = client.media_post( + media_file=media_path, mime_type=media_type + ) + + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err + + kwargs.pop("media_path", None) + + try: + media_ids: str | None = None + if media_data: + media_ids = media_data["id"] + client.status_post(media_ids=media_ids, **kwargs) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + + hass.services.async_register( + DOMAIN, SERVICE_POST, async_post, schema=SERVICE_POST_SCHEMA + ) diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml new file mode 100644 index 00000000000..161a0d152ca --- /dev/null +++ b/homeassistant/components/mastodon/services.yaml @@ -0,0 +1,30 @@ +post: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mastodon + status: + required: true + selector: + text: + visibility: + selector: + select: + options: + - public + - unlisted + - private + - direct + translation_key: post_visibility + content_warning: + selector: + text: + media: + selector: + text: + media_warning: + required: true + selector: + boolean: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 9df94ecf204..87858f768e4 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -25,6 +25,29 @@ "unknown": "Unknown error occured when connecting to the Mastodon instance." } }, + "exceptions": { + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "unable_to_send_message": { + "message": "Unable to send message." + }, + "unable_to_upload_image": { + "message": "Unable to upload image {media_path}." + }, + "not_whitelisted_directory": { + "message": "{media} is not a whitelisted directory." + } + }, + "issues": { + "deprecated_notify_action": { + "title": "Deprecated Notify action used for Mastodon", + "description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead." + } + }, "entity": { "sensor": { "followers": { @@ -40,5 +63,47 @@ "unit_of_measurement": "posts" } } + }, + "services": { + "post": { + "name": "Post", + "description": "Posts a status on your Mastodon account.", + "fields": { + "config_entry_id": { + "name": "Mastodon account", + "description": "Select the Mastodon account to post to." + }, + "status": { + "name": "Status", + "description": "The status to post." + }, + "visibility": { + "name": "Visibility", + "description": "The visibility of the post (default: account setting)." + }, + "content_warning": { + "name": "Content warning", + "description": "A content warning will be shown before the status text is shown (default: no content warning)." + }, + "media": { + "name": "Media", + "description": "Attach an image or video to the post." + }, + "media_warning": { + "name": "Media warning", + "description": "If an image or video is attached, will mark the media as sensitive (default: no media warning)." + } + } + } + }, + "selector": { + "post_visibility": { + "options": { + "public": "Public - Visible to everyone", + "unlisted": "Unlisted - Public but not shown in public timelines", + "private": "Private - Followers only", + "direct": "Direct - Mentioned accounts only" + } + } } } diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index 8e1bd697027..e9c2567b675 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -2,6 +2,9 @@ from __future__ import annotations +import mimetypes +from typing import Any + from mastodon import Mastodon from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI @@ -30,3 +33,11 @@ def construct_mastodon_username( ) return DEFAULT_NAME + + +def get_media_type(media_path: Any = None) -> Any: + """Get media type.""" + + (media_type, _) = mimetypes.guess_type(media_path) + + return media_type diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py index ab2d7456baf..4242f88d34a 100644 --- a/tests/components/mastodon/test_notify.py +++ b/tests/components/mastodon/test_notify.py @@ -2,10 +2,13 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonAPIError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -36,3 +39,27 @@ async def test_notify( ) assert mock_mastodon_client.status_post.assert_called_once + + +async def test_notify_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the notify raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + NOTIFY_DOMAIN, + "trwnh_mastodon_social", + { + "message": "test toot", + }, + blocking=True, + return_response=False, + ) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py new file mode 100644 index 00000000000..b958bcff74c --- /dev/null +++ b/tests/components/mastodon/test_services.py @@ -0,0 +1,246 @@ +"""Tests for the Mastodon services.""" + +from unittest.mock import AsyncMock, Mock, patch + +from mastodon.Mastodon import MastodonAPIError +import pytest + +from homeassistant.components.mastodon.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, +) +from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + { + "status": "test toot", + "spoiler_text": None, + "visibility": None, + "media_ids": None, + "sensitive": None, + }, + ), + ( + {ATTR_STATUS: "test toot", ATTR_VISIBILITY: "private"}, + { + "status": "test toot", + "spoiler_text": None, + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_VISIBILITY: "private", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) +async def test_service_post( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service.""" + + await setup_integration(hass, mock_config_entry) + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + } + | payload, + blocking=True, + return_response=False, + ) + + mock_mastodon_client.status_post.assert_called_with(**kwargs) + + mock_mastodon_client.status_post.reset_mock() + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + {"status": "test toot", "spoiler_text": None, "visibility": None}, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) +async def test_post_service_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.config.is_allowed_path = Mock(return_value=True) + mock_mastodon_client.media_post.return_value = {"id": "1"} + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_media_upload_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because media upload fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + mock_mastodon_client.media_post.side_effect = MastodonAPIError + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises(HomeAssistantError, match="Unable to upload image /fail.jpg"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_path_not_whitelisted( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because the file path is not whitelisted.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + with pytest.raises( + HomeAssistantError, match="/fail.jpg is not a whitelisted directory" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_service_entry_availability( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot"} + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload, + blocking=True, + return_response=False, + ) + + with pytest.raises( + ServiceValidationError, match='Integration "mastodon" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload, + blocking=True, + return_response=False, + ) From d83c335ed6926950285dda8e1c16b22db507b83a Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:45:58 +0100 Subject: [PATCH 0056/1435] Add support for standby quickmode to ViCare integration (#133156) --- .../components/vicare/binary_sensor.py | 4 +- homeassistant/components/vicare/button.py | 2 +- homeassistant/components/vicare/fan.py | 33 +- homeassistant/components/vicare/number.py | 4 +- homeassistant/components/vicare/sensor.py | 4 +- homeassistant/components/vicare/utils.py | 10 +- .../components/vicare/fixtures/VitoPure.json | 645 ++++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 64 +- tests/components/vicare/test_fan.py | 5 +- 9 files changed, 754 insertions(+), 17 deletions(-) create mode 100644 tests/components/vicare/fixtures/VitoPure.json diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index ced02dae97e..61a5abce942 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -125,7 +125,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -143,7 +143,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index ad7d600eba3..65182990bfb 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -59,7 +59,7 @@ def _build_entities( ) for device in device_list for description in BUTTON_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ] diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 190a893157c..10983a7ad24 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -5,6 +5,7 @@ from __future__ import annotations from contextlib import suppress import enum import logging +from typing import Any from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig @@ -25,7 +26,7 @@ from homeassistant.util.percentage import ( from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice -from .utils import filter_state, get_device_serial +from .utils import filter_state, get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -73,6 +74,12 @@ class VentilationMode(enum.StrEnum): return None +class VentilationQuickmode(enum.StrEnum): + """ViCare ventilation quickmodes.""" + + STANDBY = "standby" + + HA_TO_VICARE_MODE_VENTILATION = { VentilationMode.PERMANENT: "permanent", VentilationMode.VENTILATION: "ventilation", @@ -147,6 +154,19 @@ class ViCareFan(ViCareEntity, FanEntity): if supported_levels is not None and len(supported_levels) > 0: self._attr_supported_features |= FanEntityFeature.SET_SPEED + # evaluate quickmodes + quickmodes: list[str] = ( + device.getVentilationQuickmodes() + if is_supported( + "getVentilationQuickmodes", + lambda api: api.getVentilationQuickmodes(), + device, + ) + else [] + ) + if VentilationQuickmode.STANDBY in quickmodes: + self._attr_supported_features |= FanEntityFeature.TURN_OFF + def update(self) -> None: """Update state of fan.""" level: str | None = None @@ -155,6 +175,7 @@ class ViCareFan(ViCareEntity, FanEntity): self._attr_preset_mode = VentilationMode.from_vicare_mode( self._api.getActiveVentilationMode() ) + with suppress(PyViCareNotSupportedFeatureError): level = filter_state(self._api.getVentilationLevel()) if level is not None and level in ORDERED_NAMED_FAN_SPEEDS: @@ -175,8 +196,12 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - # Viessmann ventilation unit cannot be turned off - return True + return self.percentage is not None and self.percentage > 0 + + def turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + + self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) @property def icon(self) -> str | None: @@ -206,6 +231,8 @@ class ViCareFan(ViCareEntity, FanEntity): """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): self.set_preset_mode(VentilationMode.PERMANENT) + elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) _LOGGER.debug("changing ventilation level to %s", level) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 8ffaa727634..534c0752cc1 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -353,7 +353,7 @@ def _build_entities( device.api, ) for description in DEVICE_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities entities.extend( @@ -366,7 +366,7 @@ def _build_entities( ) for circuit in get_circuits(device.api) for description in CIRCUIT_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, circuit) + if is_supported(description.key, description.value_getter, circuit) ) return entities diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 091deeba2a9..c99e7857d9b 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1007,7 +1007,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -1025,7 +1025,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index a2c31df4259..ef018a60f16 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -30,7 +30,7 @@ from .const import ( VICARE_TOKEN_FILENAME, HeatingType, ) -from .types import ViCareConfigEntry, ViCareRequiredKeysMixin +from .types import ViCareConfigEntry _LOGGER = logging.getLogger(__name__) @@ -81,12 +81,12 @@ def get_device_serial(device: PyViCareDevice) -> str | None: def is_supported( name: str, - entity_description: ViCareRequiredKeysMixin, + getter: Callable[[PyViCareDevice], Any], vicare_device, ) -> bool: """Check if the PyViCare device supports the requested sensor.""" try: - entity_description.value_getter(vicare_device) + getter(vicare_device) except PyViCareNotSupportedFeatureError: _LOGGER.debug("Feature not supported %s", name) return False @@ -131,5 +131,5 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone def filter_state(state: str) -> str | None: - """Remove invalid states.""" + """Return the state if not 'nothing' or 'unknown'.""" return None if state in ("nothing", "unknown") else state diff --git a/tests/components/vicare/fixtures/VitoPure.json b/tests/components/vicare/fixtures/VitoPure.json new file mode 100644 index 00000000000..1e1cdef97ec --- /dev/null +++ b/tests/components/vicare/fixtures/VitoPure.json @@ -0,0 +1,645 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.filterChange", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.filterChange" + }, + { + "apiVersion": 1, + "commands": { + "setLevel": { + "isExecutable": true, + "name": "setLevel", + "params": { + "level": { + "constraints": { + "enum": ["levelOne", "levelTwo", "levelThree", "levelFour"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent/commands/setLevel" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.permanent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.sensorDriven", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.sensorDriven" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelTwo" + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.forcedLevelFour", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.silent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 0, + "busType": "OwnBus", + "productFamily": "B_00059_VP300", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "begin": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + }, + "end": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "device.time.daylightSaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitopure350" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["permanent", "ventilation", "sensorDriven"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "sensorDriven" + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "unknown" + }, + "level": { + "type": "string", + "value": "unknown" + }, + "reason": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 4, + "modes": ["levelOne", "levelTwo", "levelThree", "levelFour"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 3ecc4277fd9..745e77dac5c 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -60,6 +60,68 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[fan.model1_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.model1_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': 'gateway1_deviceId1-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model1_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model1 Ventilation', + 'icon': 'mdi:fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model1_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index aaf6a968ffd..5683f48f01f 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -23,7 +23,10 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] + fixtures: list[Fixture] = [ + Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), + Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.FAN]), From cde59613a58cdfccc4aae56b51412c7634c664f1 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 0057/1435] 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 f21ab24b8b812ce6e3ca63138af60877a1519a51 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 12:55:51 +0100 Subject: [PATCH 0058/1435] Add sensors for drink stats per key to lamarzocco (#136582) * Add sensors for drink stats per key to lamarzocco * Add icon * Use UOM translations * fix tests * remove translation key * Update sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/lamarzocco/icons.json | 3 + homeassistant/components/lamarzocco/sensor.py | 64 +++++- .../components/lamarzocco/strings.json | 10 +- .../lamarzocco/snapshots/test_sensor.ambr | 208 +++++++++++++++++- tests/components/lamarzocco/test_sensor.py | 1 + 5 files changed, 275 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 79267b4abd4..2be882fafea 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -95,6 +95,9 @@ "drink_stats_flushing": { "default": "mdi:chart-line" }, + "drink_stats_coffee_key": { + "default": "mdi:chart-scatter-plot" + }, "shot_timer": { "default": "mdi:timer" }, diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 406e8e40e92..a2d6143daa5 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pylamarzocco.const import BoilerType, MachineModel +from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import LaMarzoccoConfigEntry +from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity # Coordinator is used to centralize the data updates @@ -37,6 +37,15 @@ class LaMarzoccoSensorEntityDescription( value_fn: Callable[[LaMarzoccoMachine], float | int] +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoKeySensorEntityDescription( + LaMarzoccoEntityDescription, SensorEntityDescription +): + """Description of a keyed La Marzocco sensor.""" + + value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None] + + ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="shot_timer", @@ -79,7 +88,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_coffee", translation_key="drink_stats_coffee", - native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda device: device.statistics.total_coffee, available_fn=lambda device: len(device.statistics.drink_stats) > 0, @@ -88,7 +96,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_flushing", translation_key="drink_stats_flushing", - native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda device: device.statistics.total_flushes, available_fn=lambda device: len(device.statistics.drink_stats) > 0, @@ -96,6 +103,18 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ), ) +KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = ( + LaMarzoccoKeySensorEntityDescription( + key="drink_stats_coffee_key", + translation_key="drink_stats_coffee_key", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device, key: device.statistics.drink_stats.get(key), + available_fn=lambda device: len(device.statistics.drink_stats) > 0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="scale_battery", @@ -120,6 +139,8 @@ async def async_setup_entry( """Set up sensor entities.""" config_coordinator = entry.runtime_data.config_coordinator + entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = [] + entities = [ LaMarzoccoSensorEntity(config_coordinator, description) for description in ENTITIES @@ -142,6 +163,14 @@ async def async_setup_entry( if description.supported_fn(statistics_coordinator) ) + num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)] + if num_keys > 0: + entities.extend( + LaMarzoccoKeySensorEntity(statistics_coordinator, description, key) + for description in KEY_STATISTIC_ENTITIES + for key in range(1, num_keys + 1) + ) + def _async_add_new_scale() -> None: async_add_entities( LaMarzoccoScaleSensorEntity(config_coordinator, description) @@ -159,11 +188,36 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): entity_description: LaMarzoccoSensorEntityDescription @property - def native_value(self) -> int | float: + def native_value(self) -> int | float | None: """State of the sensor.""" return self.entity_description.value_fn(self.coordinator.device) +class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity): + """Sensor for a La Marzocco key.""" + + entity_description: LaMarzoccoKeySensorEntityDescription + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + description: LaMarzoccoKeySensorEntityDescription, + key: int, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description) + self.key = key + self._attr_translation_placeholders = {"key": str(key)} + self._attr_unique_id = f"{super()._attr_unique_id}_key{key}" + + @property + def native_value(self) -> int | None: + """State of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.device, PhysicalKey(self.key) + ) + + class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity): """Sensor for a La Marzocco scale.""" diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index cc96e4615dc..62050685c27 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -175,10 +175,16 @@ "name": "Current steam temperature" }, "drink_stats_coffee": { - "name": "Total coffees made" + "name": "Total coffees made", + "unit_of_measurement": "coffees" + }, + "drink_stats_coffee_key": { + "name": "Coffees made Key {key}", + "unit_of_measurement": "coffees" }, "drink_stats_flushing": { - "name": "Total flushes made" + "name": "Total flushes made", + "unit_of_measurement": "flushes" }, "shot_timer": { "name": "Shot timer" diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 9e2eae482d2..be2b1672cb9 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -50,6 +50,206 @@ 'unit_of_measurement': '%', }) # --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_1', + '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': 'Coffees made Key 1', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key1', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 1', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1047', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_2', + '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': 'Coffees made Key 2', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key2', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 2', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '560', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_3', + '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': 'Coffees made Key 3', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key3', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 3', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '468', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_4', + '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': 'Coffees made Key 4', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key4', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 4', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '312', + }) +# --- # name: test_sensors[sensor.gs012345_current_coffee_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -241,7 +441,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_coffee', 'unique_id': 'GS012345_drink_stats_coffee', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }) # --- # name: test_sensors[sensor.gs012345_total_coffees_made-state] @@ -249,7 +449,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total coffees made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }), 'context': , 'entity_id': 'sensor.gs012345_total_coffees_made', @@ -291,7 +491,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_flushing', 'unique_id': 'GS012345_drink_stats_flushing', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-state] @@ -299,7 +499,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total flushes made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }), 'context': , 'entity_id': 'sensor.gs012345_total_flushes_made', diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 3385e2b3891..43a0826d551 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -18,6 +18,7 @@ from . import async_init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, mock_lamarzocco: MagicMock, From c7041a97be79def58ffc771e2e3b2702f4c8b9cd Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:03:13 +0000 Subject: [PATCH 0059/1435] Do not duplicate device class translations in ring integration (#136868) --- .../components/ring/binary_sensor.py | 1 - homeassistant/components/ring/sensor.py | 1 - homeassistant/components/ring/strings.json | 6 - tests/components/ring/common.py | 60 ++++ .../ring/snapshots/test_binary_sensor.ambr | 6 +- .../ring/snapshots/test_sensor.ambr | 318 +++++++++++++++++- tests/components/ring/test_binary_sensor.py | 10 +- tests/components/ring/test_number.py | 5 +- tests/components/ring/test_sensor.py | 9 +- 9 files changed, 387 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 2c458985498..da0e0cc1d9b 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -55,7 +55,6 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( ), RingBinarySensorEntityDescription( key=KIND_MOTION, - translation_key=KIND_MOTION, device_class=BinarySensorDeviceClass.MOTION, capability=RingCapability.MOTION_DETECTION, deprecated_info=DeprecatedInfo( diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index cf851a113bc..a2f72b94336 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -258,7 +258,6 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( ), RingSensorEntityDescription[RingGeneric]( key="wifi_signal_strength", - translation_key="wifi_signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 8320a3ec47f..219463d92d9 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -56,9 +56,6 @@ "binary_sensor": { "ding": { "name": "Ding" - }, - "motion": { - "name": "Motion" } }, "event": { @@ -122,9 +119,6 @@ }, "wifi_signal_category": { "name": "Wi-Fi signal category" - }, - "wifi_signal_strength": { - "name": "Wi-Fi signal strength" } }, "switch": { diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index 22fa1c2bf32..e7af1d94855 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -6,6 +6,7 @@ from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.ring import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, translation from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -35,3 +36,62 @@ async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> N } }, ) + + +async def async_check_entity_translations( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_id: str, + platform_domain: str, +) -> None: + """Check that entity translations are used correctly. + + Check no unused translations in strings. + Check no translation_key defined when translation not in strings. + Check no translation defined when device class translation can be used. + """ + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + + assert entity_entries + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Limit the loaded platforms to 1 platform." + ) + + translations = await translation.async_get_translations( + hass, "en", "entity", [DOMAIN] + ) + device_class_translations = await translation.async_get_translations( + hass, "en", "entity_component", [platform_domain] + ) + unique_device_classes = set() + used_translation_keys = set() + for entity_entry in entity_entries: + dc_translation = None + if entity_entry.original_device_class: + dc_translation_key = f"component.{platform_domain}.entity_component.{entity_entry.original_device_class.value}.name" + dc_translation = device_class_translations.get(dc_translation_key) + + if entity_entry.translation_key: + key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" + entity_translation = translations.get(key) + assert entity_translation, ( + f"Translation key {entity_entry.translation_key} defined for {entity_entry.entity_id} not in strings.json" + ) + assert dc_translation != entity_translation, ( + f"Translation {key} is defined the same as the device class translation." + ) + used_translation_keys.add(key) + + else: + unique_key = (entity_entry.device_id, entity_entry.original_device_class) + assert unique_key not in unique_device_classes, ( + f"No translation key and multiple entities using {entity_entry.original_device_class}" + ) + unique_device_classes.add(entity_entry.original_device_class) + + for defined_key in translations: + if defined_key.split(".")[3] != platform_domain: + continue + assert defined_key in used_translation_keys, ( + f"Translation key {defined_key} unused." + ) diff --git a/tests/components/ring/snapshots/test_binary_sensor.ambr b/tests/components/ring/snapshots/test_binary_sensor.ambr index 2f8e4d8a219..84c727e6340 100644 --- a/tests/components/ring/snapshots/test_binary_sensor.ambr +++ b/tests/components/ring/snapshots/test_binary_sensor.ambr @@ -75,7 +75,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '987654-motion', 'unit_of_measurement': None, }) @@ -123,7 +123,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '765432-motion', 'unit_of_measurement': None, }) @@ -219,7 +219,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '345678-motion', 'unit_of_measurement': None, }) diff --git a/tests/components/ring/snapshots/test_sensor.ambr b/tests/components/ring/snapshots/test_sensor.ambr index 9fd1ac7ba84..a90bb3fe5f6 100644 --- a/tests/components/ring/snapshots/test_sensor.ambr +++ b/tests/components/ring/snapshots/test_sensor.ambr @@ -117,11 +117,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '123456-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -131,7 +131,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Downstairs Wi-Fi signal strength', + 'friendly_name': 'Downstairs Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -294,6 +294,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_door_last_ding-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.front_door_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '987654-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_door_last_motion-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.front_door_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '987654-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_door_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -412,11 +508,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '987654-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -426,7 +522,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Door Wi-Fi signal strength', + 'friendly_name': 'Front Door Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -485,6 +581,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_last_ding-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.front_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '765432-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_last_motion-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.front_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '765432-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -556,11 +748,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '765432-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -570,7 +762,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Wi-Fi signal strength', + 'friendly_name': 'Front Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -893,11 +1085,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '185036587-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -907,7 +1099,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Ingress Wi-Fi signal strength', + 'friendly_name': 'Ingress Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -1018,6 +1210,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.internal_last_ding-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.internal_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '345678-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last ding', + }), + 'context': , + 'entity_id': 'sensor.internal_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.internal_last_motion-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.internal_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '345678-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last motion', + }), + 'context': , + 'entity_id': 'sensor.internal_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.internal_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1089,11 +1377,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '345678-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -1103,7 +1391,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Internal Wi-Fi signal strength', + 'friendly_name': 'Internal Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 81d7d6e6687..c588b022265 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -18,7 +18,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_automation, setup_platform +from .common import ( + MockConfigEntry, + async_check_entity_translations, + setup_automation, + setup_platform, +) from .device_mocks import ( FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, @@ -67,6 +72,9 @@ async def test_states( ) -> None: """Test states.""" await setup_platform(hass, Platform.BINARY_SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, BINARY_SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_number.py b/tests/components/ring/test_number.py index aa484c6a7b2..9f1581742f2 100644 --- a/tests/components/ring/test_number.py +++ b/tests/components/ring/test_number.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from tests.common import snapshot_platform @@ -54,6 +54,9 @@ async def test_states( mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.NUMBER) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, NUMBER_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 48f679c4524..dcd3d5bddd6 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from .device_mocks import ( DOWNSTAIRS_DEVICE_ID, FRONT_DEVICE_ID, @@ -57,6 +57,10 @@ def create_deprecated_and_disabled_sensor_entities( create_entry("ingress", "doorbell_volume", INGRESS_DEVICE_ID) create_entry("ingress", "mic_volume", INGRESS_DEVICE_ID) create_entry("ingress", "voice_volume", INGRESS_DEVICE_ID) + for desc in ("last_motion", "last_ding"): + create_entry("front", desc, FRONT_DEVICE_ID) + create_entry("front_door", desc, FRONT_DOOR_DEVICE_ID) + create_entry("internal", desc, INTERNAL_DEVICE_ID) # Disabled for desc in ("wifi_signal_category", "wifi_signal_strength"): @@ -78,6 +82,9 @@ async def test_states( """Test states.""" mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 66f048f49f73d2c4c49583f685bb7894c856ce01 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 13:15:22 +0100 Subject: [PATCH 0060/1435] Make Reolink reboot button always available (#136667) --- homeassistant/components/reolink/button.py | 3 ++- homeassistant/components/reolink/entity.py | 4 ++++ homeassistant/components/reolink/switch.py | 19 +------------------ 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6b1fcc65a2f..c1a2aed4119 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -138,6 +138,7 @@ BUTTON_ENTITIES = ( HOST_BUTTON_ENTITIES = ( ReolinkHostButtonEntityDescription( key="reboot", + always_available=True, device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -218,7 +219,7 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): - """Base button entity class for Reolink IP cameras.""" + """Base button entity class for Reolink hosts.""" entity_description: ReolinkHostButtonEntityDescription diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 63c95c25025..e3a84579865 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -25,6 +25,7 @@ class ReolinkEntityDescription(EntityDescription): cmd_key: str | None = None cmd_id: int | None = None + always_available: bool = False @dataclass(frozen=True, kw_only=True) @@ -92,6 +93,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] @property def available(self) -> bool: """Return True if entity is available.""" + if self.entity_description.always_available: + return True + return ( self._host.api.session_active and not self._host.api.baichuan.privacy_mode() diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index cecb0b0000f..a0b8824782a 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -206,11 +206,9 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.pir_reduce_alarm(ch) is True, method=lambda api, ch, value: api.set_pir(ch, reduce_alarm=value), ), -) - -AVAILABILITY_SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="privacy_mode", + always_available=True, translation_key="privacy_mode", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "privacy_mode"), @@ -355,12 +353,6 @@ async def async_setup_entry( for entity_description in CHIME_SWITCH_ENTITIES for chime in reolink_data.host.api.chime_list ) - entities.extend( - ReolinkAvailabilitySwitchEntity(reolink_data, channel, entity_description) - for entity_description in AVAILABILITY_SWITCH_ENTITIES - for channel in reolink_data.host.api.channels - if entity_description.supported(reolink_data.host.api, channel) - ) # Can be removed in HA 2025.4.0 depricated_dict = {} @@ -426,15 +418,6 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): self.async_write_ha_state() -class ReolinkAvailabilitySwitchEntity(ReolinkSwitchEntity): - """Switch entity class for Reolink IP cameras which will be available even if API is unavailable.""" - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._host.api.camera_online(self._channel) - - class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Switch entity class for Reolink NVR features.""" From b702d88ab7b356744969dd93b9b6c320ff227cd4 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:17:22 +0100 Subject: [PATCH 0061/1435] Use runtime_data in motionmount integration (#136999) --- homeassistant/components/motionmount/__init__.py | 12 ++++++++---- .../components/motionmount/binary_sensor.py | 13 ++++++++----- homeassistant/components/motionmount/entity.py | 6 ++++-- homeassistant/components/motionmount/number.py | 16 +++++++++++----- homeassistant/components/motionmount/select.py | 10 ++++++---- homeassistant/components/motionmount/sensor.py | 13 ++++++++----- 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 9b27ce9bc6c..9c2ac6fa180 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -14,6 +14,8 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC +type MotionMountConfigEntry = ConfigEntry[motionmount.MotionMount] + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -22,7 +24,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MotionMountConfigEntry) -> bool: """Set up Vogel's MotionMount from a config entry.""" host = entry.data[CONF_HOST] @@ -65,17 +67,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Store an API object for your platforms to access - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + entry.runtime_data = mm 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: MotionMountConfigEntry +) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + mm = entry.runtime_data await mm.disconnect() return unload_ok diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index 45b6e821440..f19af67e198 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -6,19 +6,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountMovingSensor(mm, entry)]) @@ -29,7 +30,9 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize moving binary sensor entity.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-moving" diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index 57a5f638d54..81d4d0119b5 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -5,12 +5,12 @@ from typing import TYPE_CHECKING import motionmount -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity +from . import MotionMountConfigEntry from .const import DOMAIN, EMPTY_MAC _LOGGER = logging.getLogger(__name__) @@ -22,7 +22,9 @@ class MotionMountEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize general MotionMount entity.""" self.mm = mm self.config_entry = config_entry diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index b42c04a6588..6305820174f 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -5,21 +5,23 @@ import socket import motionmount from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities( ( @@ -37,7 +39,9 @@ class MotionMountExtension(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_extension" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Extension number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-extension" @@ -66,7 +70,9 @@ class MotionMountTurn(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_turn" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Turn number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-turn" diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 23fcf576af0..31c5056b91f 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -7,11 +7,11 @@ import socket import motionmount from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN, WALL_PRESET_NAME from .entity import MotionMountEntity @@ -20,10 +20,12 @@ SCAN_INTERVAL = timedelta(seconds=60) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountPresets(mm, entry)], True) @@ -37,7 +39,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): def __init__( self, mm: motionmount.MotionMount, - config_entry: ConfigEntry, + config_entry: MotionMountConfigEntry, ) -> None: """Initialize Preset selector.""" super().__init__(mm, config_entry) diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 933b637b0c2..8e55fad4a8b 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -3,19 +3,20 @@ import motionmount from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities((MotionMountErrorStatusSensor(mm, entry),)) @@ -27,7 +28,9 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): _attr_options = ["none", "motor", "internal"] _attr_translation_key = "motionmount_error_status" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize sensor entiry.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-error-status" From 8eb9cc0e8ef35273fc698878d2ea25f82981906d Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 13:19:04 +0100 Subject: [PATCH 0062/1435] 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 0773e37dab53438e6e95e4c81b46de13ebb12191 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:23:44 +0100 Subject: [PATCH 0063/1435] Create/delete lists at runtime in Bring integration (#130098) --- homeassistant/components/bring/coordinator.py | 34 +++++- homeassistant/components/bring/entity.py | 14 +-- .../components/bring/quality_scale.yaml | 4 +- homeassistant/components/bring/sensor.py | 35 ++++-- homeassistant/components/bring/todo.py | 28 +++-- tests/components/bring/fixtures/items.json | 2 +- tests/components/bring/fixtures/items2.json | 46 ++++++++ .../bring/fixtures/items_invitation.json | 2 +- .../bring/fixtures/items_shared.json | 2 +- tests/components/bring/fixtures/lists2.json | 9 ++ .../bring/snapshots/test_diagnostics.ambr | 4 +- tests/components/bring/test_diagnostics.py | 11 +- tests/components/bring/test_init.py | 101 +++++++++++++++++- tests/components/bring/test_sensor.py | 6 +- tests/components/bring/test_todo.py | 11 +- 15 files changed, 266 insertions(+), 43 deletions(-) create mode 100644 tests/components/bring/fixtures/items2.json create mode 100644 tests/components/bring/fixtures/lists2.json diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 0511d285afc..9473d0614e3 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): config_entry: ConfigEntry user_settings: BringUserSettingsResponse + lists: list[BringList] def __init__(self, hass: HomeAssistant, bring: Bring) -> None: """Initialize the Bring data coordinator.""" @@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): update_interval=timedelta(seconds=90), ) self.bring = bring + self.previous_lists: set[str] = set() async def _async_update_data(self) -> dict[str, BringData]: + """Fetch the latest data from bring.""" + try: - lists_response = await self.bring.load_lists() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: @@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): ) from exc return self.data + if self.previous_lists - ( + current_lists := {lst.listUuid for lst in self.lists} + ): + self._purge_deleted_lists() + self.previous_lists = current_lists + list_dict: dict[str, BringData] = {} - for lst in lists_response.lists: + for lst in self.lists: if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: continue try: @@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): try: await self.bring.login() self.user_settings = await self.bring.get_all_user_settings() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from e + self._purge_deleted_lists() + + def _purge_deleted_lists(self) -> None: + """Purge device entries of deleted lists.""" + + device_reg = dr.async_get(self.hass) + identifiers = { + (DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}") + for lst in self.lists + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing obsolete device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 74076d66df9..3de0140d82c 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -2,11 +2,13 @@ from __future__ import annotations +from bring_api.types import BringList + from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringDataUpdateCoordinator class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): @@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, bring_list.lst.listUuid) + super().__init__(coordinator, bring_list.listUuid) - self._list_uuid = bring_list.lst.listUuid + self._list_uuid = bring_list.listUuid self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=bring_list.lst.name, + name=bring_list.name, identifiers={ (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") }, manufacturer="Bring! Labs AG", model="Bring! Grocery Shopping List", - configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", ) diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 1fdb3f13f1b..0b4191d5c61 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -65,7 +65,7 @@ rules: status: exempt comment: | no repairs - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 02bd0e50788..651307a2eee 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -8,6 +8,7 @@ from enum import StrEnum from bring_api import BringUserSettingsResponse from bring_api.const import BRING_SUPPORTED_LOCALES +from bring_api.types import BringList from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -90,16 +91,28 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringSensorEntity( - coordinator, - bring_list, - description, - ) - for description in SENSOR_DESCRIPTIONS - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringSensorEntity( + coordinator, + bring_list, + description, + ) + for description in SENSOR_DESCRIPTIONS + 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 BringSensorEntity(BringBaseEntity, SensorEntity): @@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, entity_description: BringSensorEntityDescription, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 7ab60084314..ad4de4196c1 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -12,6 +12,7 @@ from bring_api import ( BringNotificationType, BringRequestException, ) +from bring_api.types import BringList import voluptuous as vol from homeassistant.components.todo import ( @@ -20,7 +21,7 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,14 +46,23 @@ async def async_setup_entry( ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringTodoListEntity( - coordinator, - bring_list=bring_list, - ) - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add or remove todo list entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringTodoListEntity(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() platform = entity_platform.async_get_current_platform() @@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): ) def __init__( - self, coordinator: BringDataUpdateCoordinator, bring_list: BringData + self, coordinator: BringDataUpdateCoordinator, bring_list: BringList ) -> None: """Initialize the entity.""" super().__init__(coordinator, bring_list) diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index eecdbaac8c7..02bfdc9e038 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "REGISTERED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items2.json b/tests/components/bring/fixtures/items2.json new file mode 100644 index 00000000000..c8f2a5e9d02 --- /dev/null +++ b/tests/components/bring/fixtures/items2.json @@ -0,0 +1,46 @@ +{ + "uuid": "b4776778-7f6c-496e-951b-92a35d3db0dd", + "status": "REGISTERED", + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } +} diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index be3671c359a..6b6623011da 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "INVITATION", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 5e381d27ca8..6892e07e4e6 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "SHARED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/lists2.json b/tests/components/bring/fixtures/lists2.json new file mode 100644 index 00000000000..511de7bd181 --- /dev/null +++ b/tests/components/bring/fixtures/lists2.json @@ -0,0 +1,9 @@ +{ + "lists": [ + { + "listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "name": "Einkauf", + "theme": "ch.publisheria.bring.theme.home" + } + ] +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 5955ded832a..740f4902fc3 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -47,7 +47,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', }), 'lst': dict({ 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', @@ -101,7 +101,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', diff --git a/tests/components/bring/test_diagnostics.py b/tests/components/bring/test_diagnostics.py index a86de5a0d2d..c4b8defca82 100644 --- a/tests/components/bring/test_diagnostics.py +++ b/tests/components/bring/test_diagnostics.py @@ -1,11 +1,15 @@ """Test for diagnostics platform of the Bring! integration.""" +from unittest.mock import AsyncMock + +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -16,8 +20,13 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, + mock_bring_client: AsyncMock, ) -> None: """Test diagnostics.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 8c215e024d5..a77c709315f 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -3,7 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock -from bring_api import BringAuthException, BringParseException, BringRequestException +from bring_api import ( + BringAuthException, + BringListResponse, + BringParseException, + BringRequestException, +) from freezegun.api import FrozenDateTimeFactory import pytest @@ -16,7 +21,7 @@ from homeassistant.helpers import device_registry as dr from .conftest import UUID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def setup_integration( @@ -115,6 +120,25 @@ async def test_config_entry_not_ready( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("exception", [BringRequestException, BringParseException]) +async def test_config_entry_not_ready_udpdate_failed( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + exception, + ] + 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.SETUP_RETRY + + @pytest.mark.parametrize( ("exception", "state"), [ @@ -133,7 +157,10 @@ async def test_config_entry_not_ready_auth_error( ) -> None: """Test config entry not ready from authentication error.""" - mock_bring_client.load_lists.side_effect = BringAuthException + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + BringAuthException, + ] mock_bring_client.retrieve_new_access_token.side_effect = exception bring_config_entry.add_to_hass(hass) @@ -170,3 +197,71 @@ async def test_coordinator_skips_deactivated( await hass.async_block_till_done() assert mock_bring_client.get_list.await_count == 1 + + +async def test_purge_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing device entry of deleted list.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + +async def test_create_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test create device entry for new lists.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 442fea5a247..f704debcea9 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -26,15 +26,19 @@ def sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_bring_client") async def test_setup( hass: HomeAssistant, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of sensor platform.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_todo.py b/tests/components/bring/test_todo.py index 9cc4ae3d888..9df7b892db8 100644 --- a/tests/components/bring/test_todo.py +++ b/tests/components/bring/test_todo.py @@ -4,10 +4,11 @@ from collections.abc import Generator import re from unittest.mock import AsyncMock, patch -from bring_api import BringItemOperation, BringRequestException +from bring_api import BringItemOperation, BringItemsResponse, BringRequestException import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_ITEM, @@ -21,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -40,9 +41,13 @@ async def test_todo( bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of todo platform.""" - + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() From d4a355e6847fbb8612c3446471b302017f3c4553 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:29:07 +0100 Subject: [PATCH 0064/1435] Bump python-MotionMount to 2.3.0 (#136985) --- homeassistant/components/motionmount/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 1fa3d31cfab..422be417006 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", - "requirements": ["python-MotionMount==2.2.0"], + "requirements": ["python-MotionMount==2.3.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a579ca6ba0..6bb68d58a50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pytautulli==23.1.1 pythinkingcleaner==0.0.3 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7612c8466d3..0f7cef8c557 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1912,7 +1912,7 @@ pyswitchbee==1.8.3 pytautulli==23.1.1 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 From 21ffcf853b31500cbdd1a85489395de5c81bd4dd Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 13:39:59 +0100 Subject: [PATCH 0065/1435] 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 84ae476b678fa0e593e83a01db16359ca021189b Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 31 Jan 2025 15:22:25 +0100 Subject: [PATCH 0066/1435] Energy distance units (#136933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/number/const.py | 11 +++++ .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 14 +++++++ .../components/sensor/device_condition.py | 3 ++ .../components/sensor/device_trigger.py | 3 ++ homeassistant/components/sensor/strings.json | 5 +++ homeassistant/const.py | 9 +++++ homeassistant/util/unit_conversion.py | 28 +++++++++++++ tests/util/test_unit_conversion.py | 40 +++++++++++++++++++ 10 files changed, 117 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 1a9c6c91ca7..463fcc919c7 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -166,6 +167,15 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -447,6 +457,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), + NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), NumberDeviceClass.GAS: { diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8995f57ef30..2b6640270ed 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -38,6 +38,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -147,6 +148,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { for unit in ElectricPotentialConverter.VALID_UNITS }, **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS}, + **{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS}, **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS}, **{unit: MassConverter for unit in MassConverter.VALID_UNITS}, **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS}, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ee5c5dd6d75..03d9e725170 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -25,6 +25,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -67,6 +68,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), + vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS), vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS), vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index aaa14f4637c..59a87c419e0 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -51,6 +52,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -194,6 +196,15 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -500,6 +511,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.DURATION: DurationConverter, SensorDeviceClass.ENERGY: EnergyConverter, + SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.POWER: PowerConverter, @@ -541,6 +553,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), + SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), SensorDeviceClass.FREQUENCY: set(UnitOfFrequency), SensorDeviceClass.GAS: { @@ -622,6 +635,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.ENERGY_DISTANCE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENUM: set(), SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index fc25dce18fc..4a68fbabe8f 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -48,6 +48,7 @@ CONF_IS_DATA_SIZE = "is_data_size" CONF_IS_DISTANCE = "is_distance" CONF_IS_DURATION = "is_duration" CONF_IS_ENERGY = "is_energy" +CONF_IS_ENERGY_DISTANCE = "is_energy_distance" CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" CONF_IS_GAS = "is_gas" @@ -102,6 +103,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_IS_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], @@ -168,6 +170,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_DISTANCE, CONF_IS_DURATION, CONF_IS_ENERGY, + CONF_IS_ENERGY_DISTANCE, CONF_IS_FREQUENCY, CONF_IS_GAS, CONF_IS_HUMIDITY, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d75b3aa6e41..0003b83d05a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -47,6 +47,7 @@ CONF_DATA_SIZE = "data_size" CONF_DISTANCE = "distance" CONF_DURATION = "duration" CONF_ENERGY = "energy" +CONF_ENERGY_DISTANCE = "energy_distance" CONF_FREQUENCY = "frequency" CONF_GAS = "gas" CONF_HUMIDITY = "humidity" @@ -101,6 +102,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], @@ -168,6 +170,7 @@ TRIGGER_SCHEMA = vol.All( CONF_DISTANCE, CONF_DURATION, CONF_ENERGY, + CONF_ENERGY_DISTANCE, CONF_FREQUENCY, CONF_GAS, CONF_HUMIDITY, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index d44d621f82d..dcbb4d3c826 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -17,6 +17,7 @@ "is_distance": "Current {entity_name} distance", "is_duration": "Current {entity_name} duration", "is_energy": "Current {entity_name} energy", + "is_energy_distance": "Current {entity_name} energy per distance", "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", @@ -69,6 +70,7 @@ "distance": "{entity_name} distance changes", "duration": "{entity_name} duration changes", "energy": "{entity_name} energy changes", + "energy_distance": "{entity_name} energy per distance changes", "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", @@ -183,6 +185,9 @@ "energy": { "name": "Energy" }, + "energy_distance": { + "name": "Energy per distance" + }, "energy_storage": { "name": "Stored energy" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index bdce303e64a..7775b618795 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -632,6 +632,15 @@ class UnitOfEnergy(StrEnum): GIGA_CALORIE = "Gcal" +# Energy Distance units +class UnitOfEnergyDistance(StrEnum): + """Energy Distance units.""" + + KILO_WATT_HOUR_PER_100_KM = "kWh/100km" + MILES_PER_KILO_WATT_HOUR = "mi/kWh" + KM_PER_KILO_WATT_HOUR = "km/kWh" + + # Electric_current units class UnitOfElectricCurrent(StrEnum): """Electric current units.""" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index ad320cdb9ae..67258c9cd09 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -90,6 +91,7 @@ class BaseUnitConverter: VALID_UNITS: set[str | None] _UNIT_CONVERSION: dict[str | None, float] + _UNIT_INVERSES: set[str] = set() @classmethod def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: @@ -105,6 +107,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: to_ratio / (val / from_ratio) return lambda val: (val / from_ratio) * to_ratio @classmethod @@ -129,6 +133,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: None if val is None else to_ratio / (val / from_ratio) return lambda val: None if val is None else (val / from_ratio) * to_ratio @classmethod @@ -138,6 +144,12 @@ class BaseUnitConverter: from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) return from_ratio / to_ratio + @classmethod + @lru_cache + def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: + """Return true if one unit is an inverse but not the other.""" + return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES) + class DataRateConverter(BaseUnitConverter): """Utility to convert data rate values.""" @@ -284,6 +296,22 @@ class EnergyConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfEnergy) +class EnergyDistanceConverter(BaseUnitConverter): + """Utility to convert vehicle energy consumption values.""" + + UNIT_CLASS = "energy_distance" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM: 1, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR: 100 * _KM_TO_M / _MILE_TO_M, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR: 100, + } + _UNIT_INVERSES: set[str] = { + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + } + VALID_UNITS = set(UnitOfEnergyDistance) + + class InformationConverter(BaseUnitConverter): """Utility to convert information values.""" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 1336364f4cb..aeea4ad9a5a 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -18,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -43,6 +44,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -79,6 +81,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { SpeedConverter, TemperatureConverter, UnitlessRatioConverter, + EnergyDistanceConverter, VolumeConverter, VolumeFlowRateConverter, ) @@ -115,6 +118,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo 1000, ), EnergyConverter: (UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000), + EnergyDistanceConverter: ( + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 0.621371, + ), InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8), MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), @@ -486,6 +494,38 @@ _CONVERTED_VALUE: dict[ (10, UnitOfEnergy.GIGA_CALORIE, 10000, UnitOfEnergy.MEGA_CALORIE), (10, UnitOfEnergy.GIGA_CALORIE, 11.622222, UnitOfEnergy.MEGA_WATT_HOUR), ], + EnergyDistanceConverter: [ + ( + 10, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 6.213712, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ( + 25, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 4, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 20, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 3.106856, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + ), + ( + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ], InformationConverter: [ (8e3, UnitOfInformation.BITS, 8, UnitOfInformation.KILOBITS), (8e6, UnitOfInformation.BITS, 8, UnitOfInformation.MEGABITS), From 6f1539f60defe7150777014001806182aabdc9eb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 16:32:11 +0100 Subject: [PATCH 0067/1435] 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 64814e086f255575821f508858e970f8fc057093 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 16:50:30 +0100 Subject: [PATCH 0068/1435] 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 fafeedd01bd365ba697a1050cb24c9de27e9d958 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 17:26:43 +0100 Subject: [PATCH 0069/1435] 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 f5924146c18ba3e7e9d4e77d90849d555c3df76b Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:29:59 +0100 Subject: [PATCH 0070/1435] Add data_description's to motionmount integration (#137014) * Add data_description's * Use more common terminology --- homeassistant/components/motionmount/strings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index bef04634431..1fcb6c47c99 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -11,6 +11,10 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the MotionMount.", + "port": "The port of the MotionMount." } }, "zeroconf_confirm": { @@ -22,6 +26,9 @@ "description": "Your MotionMount requires a PIN to operate.", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The user level PIN configured on the MotionMount." } }, "backoff": { From b85b834bdc7023ce3a51579a3164c64b2a001e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 31 Jan 2025 17:31:31 +0100 Subject: [PATCH 0071/1435] Bump letpot to 0.4.0 (#137007) * Bump letpot to 0.4.0 * Fix test item --- homeassistant/components/letpot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/letpot/__init__.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index 691584abc13..d08b5f70a51 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["letpot==0.3.0"] + "requirements": ["letpot==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6bb68d58a50..67d5910562c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,7 +1305,7 @@ led-ble==1.1.4 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.3.0 +letpot==0.4.0 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f7cef8c557..bebc407d809 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1104,7 +1104,7 @@ led-ble==1.1.4 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.3.0 +letpot==0.4.0 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index 829d1df54f3..ac552f907d4 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -2,7 +2,7 @@ import datetime -from letpot.models import AuthenticationInfo, LetPotDeviceStatus +from letpot.models import AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus from homeassistant.core import HomeAssistant @@ -26,6 +26,7 @@ AUTHENTICATION = AuthenticationInfo( ) STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=False), light_brightness=500, light_mode=1, light_schedule_end=datetime.time(12, 10), @@ -38,5 +39,4 @@ STATUS = LetPotDeviceStatus( raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], system_on=True, system_sound=False, - system_state=0, ) From e18dc063ba7da827506defa4f06189bf17cd44d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 17:33:30 +0100 Subject: [PATCH 0072/1435] 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 b1c3d0857a712f6f835c3de07169c4a0db693b21 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 31 Jan 2025 09:35:08 -0700 Subject: [PATCH 0073/1435] Add pets to litterrobot integration (#136865) --- .../components/litterrobot/__init__.py | 9 ++++- .../components/litterrobot/binary_sensor.py | 12 +++--- .../components/litterrobot/button.py | 10 ++--- .../components/litterrobot/coordinator.py | 2 + .../components/litterrobot/entity.py | 40 +++++++++++++------ .../components/litterrobot/select.py | 20 +++++----- .../components/litterrobot/sensor.py | 32 +++++++++++---- .../components/litterrobot/switch.py | 12 +++--- homeassistant/components/litterrobot/time.py | 12 +++--- tests/components/litterrobot/common.py | 10 +++++ tests/components/litterrobot/conftest.py | 19 ++++++++- tests/components/litterrobot/test_sensor.py | 10 +++++ 12 files changed, 133 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 1f926d37a61..2823450d9ad 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import itertools + from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -46,6 +48,9 @@ async def async_remove_config_entry_device( identifier for identifier in device_entry.identifiers if identifier[0] == DOMAIN - for robot in entry.runtime_data.account.robots - if robot.serial == identifier[1] + for _id in itertools.chain( + (robot.serial for robot in entry.runtime_data.account.robots), + (pet.id for pet in entry.runtime_data.account.pets), + ) + if _id == identifier[1] ) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index e6cf23fa27c..700985d285f 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -18,16 +18,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) class RobotBinarySensorEntityDescription( - BinarySensorEntityDescription, Generic[_RobotT] + BinarySensorEntityDescription, Generic[_WhiskerEntityT] ): """A class that describes robot binary sensor entities.""" - is_on_fn: Callable[[_RobotT], bool] + is_on_fn: Callable[[_WhiskerEntityT], bool] BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { @@ -78,10 +78,12 @@ async def async_setup_entry( ) -class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity): +class LitterRobotBinarySensorEntity( + LitterRobotEntity[_WhiskerEntityT], BinarySensorEntity +): """Litter-Robot binary sensor entity.""" - entity_description: RobotBinarySensorEntityDescription[_RobotT] + entity_description: RobotBinarySensorEntityDescription[_WhiskerEntityT] @property def is_on(self) -> bool: diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 01888e7fbae..758548b3a67 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -14,14 +14,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_RobotT]): +class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot button entities.""" - press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] + press_fn: Callable[[_WhiskerEntityT], Coroutine[Any, Any, bool]] ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = { @@ -62,10 +62,10 @@ async def async_setup_entry( ) -class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): +class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity): """Litter-Robot button entity.""" - entity_description: RobotButtonEntityDescription[_RobotT] + entity_description: RobotButtonEntityDescription[_WhiskerEntityT] async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index a56a6607d32..c99d4794ff6 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -47,6 +47,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Update all device states from the Litter-Robot API.""" await self.account.refresh_robots() + await self.account.load_pets() async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -56,6 +57,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): password=self.config_entry.data[CONF_PASSWORD], load_robots=True, subscribe_for_updates=True, + load_pets=True, ) except LitterRobotLoginException as ex: raise ConfigEntryAuthFailed("Invalid credentials") from ex diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 36cbbb730ce..9e9cc8f0740 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Generic, TypeVar -from pylitterbot import Robot +from pylitterbot import Pet, Robot from pylitterbot.robot import EVENT_UPDATE from homeassistant.helpers.device_registry import DeviceInfo @@ -14,11 +14,31 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import LitterRobotDataUpdateCoordinator -_RobotT = TypeVar("_RobotT", bound=Robot) +_WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet) + + +def get_device_info(whisker_entity: _WhiskerEntityT) -> DeviceInfo: + """Get device info for a robot or pet.""" + if isinstance(whisker_entity, Robot): + return DeviceInfo( + identifiers={(DOMAIN, whisker_entity.serial)}, + manufacturer="Whisker", + model=whisker_entity.model, + name=whisker_entity.name, + serial_number=whisker_entity.serial, + sw_version=getattr(whisker_entity, "firmware", None), + ) + breed = ", ".join(breed for breed in whisker_entity.breeds or []) + return DeviceInfo( + identifiers={(DOMAIN, whisker_entity.id)}, + manufacturer="Whisker", + model=f"{breed} {whisker_entity.pet_type}".strip().capitalize(), + name=whisker_entity.name, + ) class LitterRobotEntity( - CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_RobotT] + CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_WhiskerEntityT] ): """Generic Litter-Robot entity representing common data and methods.""" @@ -26,7 +46,7 @@ class LitterRobotEntity( def __init__( self, - robot: _RobotT, + robot: _WhiskerEntityT, coordinator: LitterRobotDataUpdateCoordinator, description: EntityDescription, ) -> None: @@ -34,15 +54,9 @@ class LitterRobotEntity( super().__init__(coordinator) self.robot = robot self.entity_description = description - self._attr_unique_id = f"{robot.serial}-{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, robot.serial)}, - manufacturer="Whisker", - model=robot.model, - name=robot.name, - serial_number=robot.serial, - sw_version=getattr(robot, "firmware", None), - ) + _id = robot.serial if isinstance(robot, Robot) else robot.id + self._attr_unique_id = f"{_id}-{description.key}" + self._attr_device_info = get_device_info(robot) async def async_added_to_hass(self) -> None: """Set up a listener for the entity.""" diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 1a3d2fc2fb4..f6e3781f3df 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -15,21 +15,21 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT _CastTypeT = TypeVar("_CastTypeT", int, float, str) @dataclass(frozen=True, kw_only=True) class RobotSelectEntityDescription( - SelectEntityDescription, Generic[_RobotT, _CastTypeT] + SelectEntityDescription, Generic[_WhiskerEntityT, _CastTypeT] ): """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - current_fn: Callable[[_RobotT], _CastTypeT | None] - options_fn: Callable[[_RobotT], list[_CastTypeT]] - select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] + current_fn: Callable[[_WhiskerEntityT], _CastTypeT | None] + options_fn: Callable[[_WhiskerEntityT], list[_CastTypeT]] + select_fn: Callable[[_WhiskerEntityT, str], Coroutine[Any, Any, bool]] ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { @@ -83,17 +83,19 @@ async def async_setup_entry( class LitterRobotSelectEntity( - LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] + LitterRobotEntity[_WhiskerEntityT], + SelectEntity, + Generic[_WhiskerEntityT, _CastTypeT], ): """Litter-Robot Select.""" - entity_description: RobotSelectEntityDescription[_RobotT, _CastTypeT] + entity_description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT] def __init__( self, - robot: _RobotT, + robot: _WhiskerEntityT, coordinator: LitterRobotDataUpdateCoordinator, - description: RobotSelectEntityDescription[_RobotT, _CastTypeT], + description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT], ) -> None: """Initialize a Litter-Robot select entity.""" super().__init__(robot, coordinator, description) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 6545d7c7ae7..3e25a0556c6 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Pet, Robot from homeassistant.components.sensor import ( SensorDeviceClass, @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str: @@ -35,11 +35,11 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str @dataclass(frozen=True, kw_only=True) -class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): +class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None - value_fn: Callable[[_RobotT], float | datetime | str | None] + value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None] ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { @@ -146,6 +146,16 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { ], } +PET_SENSORS: list[RobotSensorEntityDescription] = [ + RobotSensorEntityDescription[Pet]( + key="weight", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.POUNDS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda pet: pet.weight, + ) +] + async def async_setup_entry( hass: HomeAssistant, @@ -154,7 +164,7 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot sensors using config entry.""" coordinator = entry.runtime_data - async_add_entities( + entities: list[LitterRobotSensorEntity] = [ LitterRobotSensorEntity( robot=robot, coordinator=coordinator, description=description ) @@ -162,13 +172,21 @@ async def async_setup_entry( for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions + ] + entities.extend( + LitterRobotSensorEntity( + robot=pet, coordinator=coordinator, description=description + ) + for pet in coordinator.account.pets + for description in PET_SENSORS ) + async_add_entities(entities) -class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): +class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity): """Litter-Robot sensor entity.""" - entity_description: RobotSensorEntityDescription[_RobotT] + entity_description: RobotSensorEntityDescription[_WhiskerEntityT] @property def native_value(self) -> float | datetime | str | None: diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 7ded89d552b..4839748c068 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -14,16 +14,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_RobotT]): +class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot switch entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] - value_fn: Callable[[_RobotT], bool] + set_fn: Callable[[_WhiskerEntityT, bool], Coroutine[Any, Any, bool]] + value_fn: Callable[[_WhiskerEntityT], bool] ROBOT_SWITCHES = [ @@ -57,10 +57,10 @@ async def async_setup_entry( ) -class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): +class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity): """Litter-Robot switch entity.""" - entity_description: RobotSwitchEntityDescription[_RobotT] + entity_description: RobotSwitchEntityDescription[_WhiskerEntityT] @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 3fa93b14dd9..69d81d63eae 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -16,15 +16,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotTimeEntityDescription(TimeEntityDescription, Generic[_RobotT]): +class RobotTimeEntityDescription(TimeEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot time entities.""" - value_fn: Callable[[_RobotT], time | None] - set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] + value_fn: Callable[[_WhiskerEntityT], time | None] + set_fn: Callable[[_WhiskerEntityT, time], Coroutine[Any, Any, bool]] def _as_local_time(start: datetime | None) -> time | None: @@ -64,10 +64,10 @@ async def async_setup_entry( ) -class LitterRobotTimeEntity(LitterRobotEntity[_RobotT], TimeEntity): +class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity): """Litter-Robot time entity.""" - entity_description: RobotTimeEntityDescription[_RobotT] + entity_description: RobotTimeEntityDescription[_WhiskerEntityT] @property def native_value(self) -> time | None: diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index b29fa753801..d96ce06ca59 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -150,5 +150,15 @@ FEEDER_ROBOT_DATA = { }, ], } +PET_DATA = { + "petId": "PET-123", + "userId": "1234567", + "createdAt": "2023-04-27T23:26:49.813Z", + "name": "Kitty", + "type": "CAT", + "gender": "FEMALE", + "lastWeightReading": 9.1, + "breeds": ["sphynx"], +} VACUUM_ENTITY_ID = "vacuum.test_litter_box" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index e60e0cbd36d..d22c4b2ec49 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -5,13 +5,20 @@ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Robot +from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot.exceptions import InvalidCommandException import pytest from homeassistant.core import HomeAssistant -from .common import CONFIG, DOMAIN, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA +from .common import ( + CONFIG, + DOMAIN, + FEEDER_ROBOT_DATA, + PET_DATA, + ROBOT_4_DATA, + ROBOT_DATA, +) from tests.common import MockConfigEntry @@ -50,6 +57,7 @@ def create_mock_account( skip_robots: bool = False, v4: bool = False, feeder: bool = False, + pet: bool = False, ) -> MagicMock: """Create a mock Litter-Robot account.""" account = MagicMock(spec=Account) @@ -60,6 +68,7 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) + account.pets = [Pet(PET_DATA, account.session)] if pet else [] return account @@ -81,6 +90,12 @@ def mock_account_with_feederrobot() -> MagicMock: return create_mock_account(feeder=True) +@pytest.fixture +def mock_account_with_pet() -> MagicMock: + """Mock account with Feeder-Robot.""" + return create_mock_account(pet=True) + + @pytest.fixture def mock_account_with_no_robots() -> MagicMock: """Mock a Litter-Robot account.""" diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 360d13096a7..e290d96fcf4 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -104,3 +104,13 @@ async def test_feeder_robot_sensor( sensor = hass.states.get("sensor.test_food_level") assert sensor.state == "10" assert sensor.attributes["unit_of_measurement"] == PERCENTAGE + + +async def test_pet_weight_sensor( + hass: HomeAssistant, mock_account_with_pet: MagicMock +) -> None: + """Tests pet weight sensors.""" + await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.kitty_weight") + assert sensor.state == "9.1" + assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS From e0bf248867b244866e01039f3a95f56193d9ab5b 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 0074/1435] 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 67d5910562c..637e25cec04 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 bebc407d809..ebe7c1e9fe7 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 64f679ba8f065d04875c76f9db00b138f5da985f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 18:20:30 +0100 Subject: [PATCH 0075/1435] 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 b9439183d8c..24a1743155e 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 @@ -38,11 +38,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 @@ -113,12 +116,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, @@ -174,7 +180,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, @@ -301,6 +308,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( @@ -314,6 +324,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 c4cb94bddd92a6400ad79ee3b2b07564fd560175 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 11:29:44 -0600 Subject: [PATCH 0076/1435] 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 637e25cec04..f2a36a4329e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,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 ebe7c1e9fe7..3a26c0786d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,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 f8f12957b5c8ba1d62005c1e41388e48a13d0815 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:15:31 -0600 Subject: [PATCH 0077/1435] 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 f2a36a4329e..b2695471121 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,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 3a26c0786d9..fdb6c498f34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,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 256157d41377c4e01cb8696e16834f46eb77fcfe Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Jan 2025 19:25:24 +0100 Subject: [PATCH 0078/1435] 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 b2695471121..5e182110235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==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 fdb6c498f34..86557711111 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 065cdf421f947451599c67d83b8a4725b99ab4d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 19:33:48 +0100 Subject: [PATCH 0079/1435] 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 24a1743155e..495e953df9d 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, @@ -54,6 +55,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__) @@ -625,10 +628,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, @@ -639,6 +652,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 9bc3c417aea0d949c33cb07021d2eea86100ea34 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 31 Jan 2025 19:36:40 +0100 Subject: [PATCH 0080/1435] Add codeowner to Home Connect (#137029) --- CODEOWNERS | 4 ++-- homeassistant/components/home_connect/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7baeea72178..635f53d346f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,8 +625,8 @@ build.json @home-assistant/supervisor /tests/components/hlk_sw16/ @jameshilliard /homeassistant/components/holiday/ @jrieger @gjohansson-ST /tests/components/holiday/ @jrieger @gjohansson-ST -/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 -/tests/components/home_connect/ @DavidMStraub @Diegorro98 +/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare +/tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 905a7c67f11..1d9f3f363aa 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -1,7 +1,7 @@ { "domain": "home_connect", "name": "Home Connect", - "codeowners": ["@DavidMStraub", "@Diegorro98"], + "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/home_connect", From df59b1d4fac0678b8a1371c9d0083be63e7d6185 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 31 Jan 2025 10:45:01 -0800 Subject: [PATCH 0081/1435] 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 92dd18a9bed750869a460f45159fcefcfafdeec1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 19:48:47 +0100 Subject: [PATCH 0082/1435] 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 f75a61ac904f689a7e9df233ade94c0bf8672991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:52:38 -0600 Subject: [PATCH 0083/1435] 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 74e3d51a222..3ad3240907c 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 5e182110235..1cfea1bb0e1 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 86557711111..7b77388556d 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 df166d178c8fc2b3f7589af6bf6a8d0790c6c776 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 31 Jan 2025 20:17:14 +0100 Subject: [PATCH 0084/1435] 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 1cfea1bb0e1..bb48565e2ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -750,7 +750,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 7b77388556d..1695de16332 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,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 4a2e9db9fe91f7d64ecbcf09559a22dc3beedfdb Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 20:59:34 +0100 Subject: [PATCH 0085/1435] 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 164d38ac0df5b590ef18dd0bc9481da1e674da85 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Fri, 31 Jan 2025 21:03:17 +0100 Subject: [PATCH 0086/1435] 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 bb48565e2ee..b6b21975aee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,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 1695de16332..d8c9fd3613d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,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 7103ea7e8f4a0f9def0731829f18c30cc3d1d5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 31 Jan 2025 21:28:23 +0100 Subject: [PATCH 0087/1435] Add exception handling for updating LetPot time entities (#137033) * Handle exceptions for entity edits for LetPot * Set exception-translations: done --- homeassistant/components/letpot/entity.py | 30 +++++++++++ .../components/letpot/quality_scale.yaml | 4 +- homeassistant/components/letpot/strings.json | 8 +++ homeassistant/components/letpot/time.py | 3 +- tests/components/letpot/test_time.py | 52 +++++++++++++++++++ 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/components/letpot/test_time.py diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index c9a8953b5d5..b4d505f4092 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -1,5 +1,11 @@ """Base class for LetPot entities.""" +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from letpot.exceptions import LetPotConnectionException, LetPotException + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -23,3 +29,27 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): model_id=coordinator.device_client.device_model_code, serial_number=coordinator.device.serial_number, ) + + +def exception_handler[_EntityT: LetPotEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate the function to catch LetPot exceptions and raise them correctly.""" + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except LetPotConnectionException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"exception": str(exception)}, + ) from exception + except LetPotException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"exception": str(exception)}, + ) from exception + + return handler diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 74b948ffbf7..7f8c3d3c04c 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -29,7 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: status: done comment: | @@ -63,7 +63,7 @@ rules: entity-device-class: todo entity-disabled-by-default: todo entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 93913c2bc4d..94d3ad02cfa 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -40,5 +40,13 @@ "name": "Light on" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the LetPot device: {exception}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the LetPot device: {exception}" + } } } diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index 229f02e0806..80ce9743d8c 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LetPotConfigEntry from .coordinator import LetPotDeviceCoordinator -from .entity import LetPotEntity +from .entity import LetPotEntity, exception_handler # Each change pushes a 'full' device status with the change. The library will cache # pending changes to avoid overwriting, but try to avoid a lot of parallelism. @@ -86,6 +86,7 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity): """Return the time.""" return self.entity_description.value_fn(self.coordinator.data) + @exception_handler async def async_set_value(self, value: time) -> None: """Set the time.""" await self.entity_description.set_value_fn( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py new file mode 100644 index 00000000000..44a03e565c0 --- /dev/null +++ b/tests/components/letpot/test_time.py @@ -0,0 +1,52 @@ +"""Test time entities for the LetPot integration.""" + +from datetime import time +from unittest.mock import MagicMock + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest + +from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_time_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test time entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_light_schedule.side_effect = exception + + assert hass.states.get("time.garden_light_on") is not None + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=7, minute=0)}, + blocking=True, + target={"entity_id": "time.garden_light_on"}, + ) From d51e72cd9500c201f9e11c363fe7d8ce63519406 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 21:29:31 +0100 Subject: [PATCH 0088/1435] 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 7a0400154e4a4e4f5f2def5b7b64c9aa17ee8094 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:00:39 -0600 Subject: [PATCH 0089/1435] 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 3ad3240907c..5c3b794569c 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 b6b21975aee..4e950d754f4 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 d8c9fd3613d..e894044334f 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 dc7f44535639bf9c55965a58ef8db4ba30157ea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:18:19 -0600 Subject: [PATCH 0090/1435] 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 4e950d754f4..80ac251e862 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,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 e894044334f..492b67251fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,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 5fa5bd130273a71f922730be49993b47c7b50e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 16:30:20 -0600 Subject: [PATCH 0091/1435] 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 5c3b794569c..afed8fd7091 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 7040614433de7cf44c3ee1e1defcf5381eb6aef4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 23:56:45 +0100 Subject: [PATCH 0092/1435] Fix one occurrence of "api" to match all other in sensibo and HA (#137037) --- homeassistant/components/sensibo/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index c5ff0f135e6..6c5210d12bf 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -18,7 +18,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Follow the [documentation]({url}) to get your api key" + "api_key": "Follow the [documentation]({url}) to get your API key" } }, "reauth_confirm": { From c35e7715b7c830e23de90751b44da2521c155d4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 18:13:27 -0600 Subject: [PATCH 0093/1435] 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 80ac251e862..a4df828bb66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,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 492b67251fc..ac40911c5bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,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 e56772d37b1cba5143bbf8042c1f78c0587d5585 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Feb 2025 01:38:11 +0100 Subject: [PATCH 0094/1435] 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 a4df828bb66..ce9c538fbc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,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 ac40911c5bc..bd338b85532 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -261,7 +261,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 5da9bfe0e3b658e12baa710948b99ae1cc5e7cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 1 Feb 2025 01:03:20 +0000 Subject: [PATCH 0095/1435] Add dev docs and frontend PR links to PR template (#137034) --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23365feffb7..792dacd8032 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -46,6 +46,8 @@ - This PR fixes or closes issue: fixes # - This PR is related to issue: - Link to documentation pull request: +- Link to developer documentation pull request: +- Link to frontend pull request: ## Checklist 2: Use config entry ID as base for unique IDs. @@ -152,7 +131,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def _async_migrate_device_identifiers( - hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None + hass: HomeAssistant, + config_entry: MinecraftServerConfigEntry, + old_unique_id: str | None, ) -> None: """Migrate the device identifiers to the new format.""" device_registry = dr.async_get(hass) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index d2c8aca57e4..39e12228451 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -5,12 +5,10 @@ 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MinecraftServerCoordinator +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity KEY_STATUS = "status" @@ -27,11 +25,11 @@ BINARY_SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add binary sensor entities. async_add_entities( @@ -49,7 +47,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit self, coordinator: MinecraftServerCoordinator, description: BinarySensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize binary sensor base entity.""" super().__init__(coordinator, config_entry) diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f66e4acf214..2cd1c1a94ab 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -6,17 +6,22 @@ from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( MinecraftServer, + MinecraftServerAddressError, MinecraftServerConnectionError, MinecraftServerData, MinecraftServerNotInitializedError, + MinecraftServerType, ) +type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator] + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -25,16 +30,15 @@ _LOGGER = logging.getLogger(__name__) class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - config_entry: ConfigEntry + config_entry: MinecraftServerConfigEntry + _api: MinecraftServer def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - api: MinecraftServer, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize coordinator instance.""" - self._api = api super().__init__( hass=hass, @@ -44,6 +48,22 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): update_interval=SCAN_INTERVAL, ) + async def _async_setup(self) -> None: + """Set up the Minecraft Server data coordinator.""" + + # Create API instance. + self._api = MinecraftServer( + self.hass, + self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + self.config_entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. + try: + await self._api.async_initialize() + except MinecraftServerAddressError as error: + raise ConfigEntryNotReady(f"Initialization failed: {error}") from error + async def _async_update_data(self) -> MinecraftServerData: """Get updated data from the server.""" try: diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 0bcffe1434a..61a65f9c2dd 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -5,20 +5,19 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import MinecraftServerConfigEntry TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MinecraftServerConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "config_entry": { diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index fc3db3b3075..eeda413f2ad 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -29,7 +29,7 @@ rules: status: done comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: status: done diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 50571123003..6effa53fbf2 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,15 +7,14 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .api import MinecraftServerData, MinecraftServerType -from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator +from .const import KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity ATTR_PLAYERS_LIST = "players_list" @@ -158,11 +157,11 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add sensor entities. async_add_entities( @@ -184,7 +183,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): self, coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize sensor base entity.""" super().__init__(coordinator, config_entry) From 648c750a0fd2e7a7da4fe8e78b1dc38402f0f23b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 13:21:21 -0600 Subject: [PATCH 1133/1435] Bump ulid-transform to 1.2.1 (#139054) changelog: https://github.com/Bluetooth-Devices/ulid-transform/compare/v1.2.0...v1.2.1 --- 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 7847599223c..40f7e511332 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.0 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index 0a4228496e3..b43e4d284ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.2.0", + "ulid-transform==1.2.1", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 diff --git a/requirements.txt b/requirements.txt index 2bacda6b017..962cab71a53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.0 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous==0.15.2 From f3dd772b4386b94f5d96477c55f614ae2e607459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederic=20Mari=C3=ABn?= Date: Sat, 22 Feb 2025 20:25:19 +0100 Subject: [PATCH 1134/1435] Bump pyrisco to 0.6.7 (#139065) --- homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 149b8761589..43d471172d6 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.5"] + "requirements": ["pyrisco==0.6.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90065832988..7596d1e7d5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2250,7 +2250,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1017a3c420..0e868a77f0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,7 +1834,7 @@ pyrail==0.0.3 pyrainbird==6.0.1 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 From 6c0c4bfd74eedf8a7faf84edc378f06d25e83170 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:53:53 +0100 Subject: [PATCH 1135/1435] Bump pyfritzhome to 0.6.17 (#139066) bump pyfritzhome to 0.6.17 --- homeassistant/components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 92405a977ee..f6155024cbf 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.16"], + "requirements": ["pyfritzhome==0.6.17"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 7596d1e7d5f..0ffd8b7e781 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1972,7 +1972,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.16 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e868a77f0c..6d070883303 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1607,7 +1607,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.16 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 From a0c278135590a8cc65ae344838f39cbf6682225c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Feb 2025 20:56:05 +0100 Subject: [PATCH 1136/1435] Fix docstring parameter in entity platform (#139070) Fix docstring --- homeassistant/helpers/entity_platform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index adf34f3b285..11a9786f86e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -659,7 +659,7 @@ class EntityPlatform: This method must be run in the event loop. - :param subentry_id: subentry which the entities should be added to + :param config_subentry_id: subentry which the entities should be added to """ if config_subentry_id and ( not self.config_entry From 92788a04ff0f86d17130e022b606e487af5d0b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Feb 2025 21:08:39 +0100 Subject: [PATCH 1137/1435] Add entities that represent program options to Home Connect (#138674) * Add program options as entities * Use program options constraints * Only fetch the available options on refresh * Extract the option definitions getter from the loop * Add the option entities only when it is required * Fix typo --- .../components/home_connect/common.py | 102 +++++- .../components/home_connect/coordinator.py | 101 +++++- .../components/home_connect/entity.py | 63 +++- .../components/home_connect/icons.json | 33 ++ .../components/home_connect/number.py | 91 +++++- .../components/home_connect/select.py | 245 +++++++++++++- .../components/home_connect/sensor.py | 8 +- .../components/home_connect/strings.json | 251 +++++++++++++++ .../components/home_connect/switch.py | 89 +++++- tests/components/home_connect/conftest.py | 41 +++ .../home_connect/fixtures/settings.json | 5 + .../snapshots/test_diagnostics.ambr | 1 + tests/components/home_connect/test_entity.py | 299 ++++++++++++++++++ tests/components/home_connect/test_number.py | 163 +++++++++- tests/components/home_connect/test_select.py | 152 ++++++++- tests/components/home_connect/test_switch.py | 118 ++++++- 16 files changed, 1729 insertions(+), 33 deletions(-) create mode 100644 tests/components/home_connect/test_entity.py diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index c27230c01d8..a9f48eea5ba 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -1,5 +1,6 @@ """Common callbacks for all Home Connect platforms.""" +from collections import defaultdict from collections.abc import Callable from functools import partial from typing import cast @@ -9,7 +10,32 @@ from aiohomeconnect.model import EventKey from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity + + +def _create_option_entities( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, + known_entity_unique_ids: dict[str, str], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ], + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create the required option entities for the appliances.""" + option_entities_to_add = [ + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ] + known_entity_unique_ids.update( + { + cast(str, entity.unique_id): appliance.info.ha_id + for entity in option_entities_to_add + } + ) + async_add_entities(option_entities_to_add) def _handle_paired_or_connected_appliance( @@ -18,6 +44,12 @@ def _handle_paired_or_connected_appliance( get_entities_for_appliance: Callable[ [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None, + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Handle a new paired appliance or an appliance that has been connected. @@ -34,6 +66,28 @@ def _handle_paired_or_connected_appliance( for entity in get_entities_for_appliance(entry, appliance) if entity.unique_id not in known_entity_unique_ids ] + if get_option_entities_for_appliance: + entities_to_add.extend( + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ) + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -47,11 +101,17 @@ def _handle_paired_or_connected_appliance( def _handle_depaired_appliance( entry: HomeConnectConfigEntry, known_entity_unique_ids: dict[str, str], + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], ) -> None: """Handle a removed appliance.""" for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items(): if appliance_id not in entry.runtime_data.data: known_entity_unique_ids.pop(entity_unique_id, None) + if appliance_id in changed_options_listener_remove_callbacks: + for listener in changed_options_listener_remove_callbacks.pop( + appliance_id + ): + listener() def setup_home_connect_entry( @@ -60,13 +120,44 @@ def setup_home_connect_entry( [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], async_add_entities: AddConfigEntryEntitiesCallback, + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None = None, ) -> None: """Set up the callbacks for paired and depaired appliances.""" known_entity_unique_ids: dict[str, str] = {} + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = ( + defaultdict(list) + ) entities: list[HomeConnectEntity] = [] for appliance in entry.runtime_data.data.values(): entities_to_add = get_entities_for_appliance(entry, appliance) + if get_option_entities_for_appliance: + entities_to_add.extend(get_option_entities_for_appliance(entry, appliance)) + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + (appliance.info.ha_id, event_key), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -83,6 +174,8 @@ def setup_home_connect_entry( entry, known_entity_unique_ids, get_entities_for_appliance, + get_option_entities_for_appliance, + changed_options_listener_remove_callbacks, async_add_entities, ), ( @@ -93,7 +186,12 @@ def setup_home_connect_entry( ) entry.async_on_unload( entry.runtime_data.async_add_special_listener( - partial(_handle_depaired_appliance, entry, known_entity_unique_ids), + partial( + _handle_depaired_appliance, + entry, + known_entity_unique_ids, + changed_options_listener_remove_callbacks, + ), (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), ) ) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index ceedde7fe72..b5f0f711597 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -7,7 +7,7 @@ from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any +from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( @@ -17,6 +17,8 @@ from aiohomeconnect.model import ( EventType, GetSetting, HomeAppliance, + OptionKey, + ProgramKey, SettingKey, Status, StatusKey, @@ -28,7 +30,7 @@ from aiohomeconnect.model.error import ( HomeConnectRequestError, UnauthorizedError, ) -from aiohomeconnect.model.program import EnumerateProgram +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -53,6 +55,7 @@ class HomeConnectApplianceData: events: dict[EventKey, Event] info: HomeAppliance + options: dict[OptionKey, ProgramDefinitionOption] programs: list[EnumerateProgram] settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -61,6 +64,8 @@ class HomeConnectApplianceData: """Update data with data from other instance.""" self.events.update(other.events) self.info.connected = other.info.connected + self.options.clear() + self.options.update(other.options) self.programs.clear() self.programs.extend(other.programs) self.settings.update(other.settings) @@ -172,8 +177,9 @@ class HomeConnectCoordinator( settings = self.data[event_message_ha_id].settings events = self.data[event_message_ha_id].events for event in event_message.data.items: - if event.key in SettingKey: - setting_key = SettingKey(event.key) + event_key = event.key + if event_key in SettingKey: + setting_key = SettingKey(event_key) if setting_key in settings: settings[setting_key].value = event.value else: @@ -183,7 +189,16 @@ class HomeConnectCoordinator( value=event.value, ) else: - events[event.key] = event + if event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + await self.update_options( + event_message_ha_id, + event_key, + ProgramKey(cast(str, event.value)), + ) + events[event_key] = event self._call_event_listener(event_message) case EventType.EVENT: @@ -338,6 +353,7 @@ class HomeConnectCoordinator( programs = [] events = {} + options = {} if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) @@ -351,15 +367,17 @@ class HomeConnectCoordinator( ) else: programs.extend(all_programs.programs) + current_program_key = None + program_options = None for program, event_key in ( - ( - all_programs.active, - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - ), ( all_programs.selected, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), + ( + all_programs.active, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), ): if program and program.key: events[event_key] = Event( @@ -370,10 +388,30 @@ class HomeConnectCoordinator( "", program.key, ) + current_program_key = program.key + program_options = program.options + if current_program_key: + options = await self.get_options_definitions( + appliance.ha_id, current_program_key + ) + for option in program_options or []: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key, + 0, + "", + "", + option.value, + option.name, + display_value=option.display_value, + unit=option.unit, + ) appliance_data = HomeConnectApplianceData( events=events, info=appliance, + options=options, programs=programs, settings=settings, status=status, @@ -383,3 +421,48 @@ class HomeConnectCoordinator( appliance_data = appliance_data_to_update return appliance_data + + async def get_options_definitions( + self, ha_id: str, program_key: ProgramKey + ) -> dict[OptionKey, ProgramDefinitionOption]: + """Get options with constraints for appliance.""" + return { + option.key: option + for option in ( + await self.client.get_available_program(ha_id, program_key=program_key) + ).options + or [] + } + + async def update_options( + self, ha_id: str, event_key: EventKey, program_key: ProgramKey + ) -> None: + """Update options for appliance.""" + options = self.data[ha_id].options + events = self.data[ha_id].events + options_to_notify = options.copy() + options.clear() + if program_key is not ProgramKey.UNKNOWN: + options.update(await self.get_options_definitions(ha_id, program_key)) + + for option in options.values(): + option_value = option.constraints.default if option.constraints else None + if option_value is not None: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key.value, + 0, + "", + "", + option_value, + option.name, + unit=option.unit, + ) + options_to_notify.update(options) + for option_key in options_to_notify: + for listener in self.context_listeners.get( + (ha_id, EventKey(option_key)), + [], + ): + listener() diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 8eb9d757f14..52eaaecace7 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,17 +1,22 @@ """Home Connect entity base class.""" from abc import abstractmethod +import contextlib import logging +from typing import cast -from aiohomeconnect.model import EventKey +from aiohomeconnect.model import EventKey, OptionKey +from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -60,3 +65,59 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): return ( self.appliance.info.connected and self._attr_available and super().available ) + + +class HomeConnectOptionEntity(HomeConnectEntity): + """Class for entities that represents program options.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.bsh_key in self.appliance.options + + @property + def option_value(self) -> str | int | float | bool | None: + """Return the state of the entity.""" + if event := self.appliance.events.get(EventKey(self.bsh_key)): + return event.value + return None + + async def async_set_option(self, value: str | float | bool) -> None: + """Set an option for the entity.""" + try: + # We try to set the active program option first, + # if it fails we try to set the selected program option + with contextlib.suppress(ActiveProgramNotSetError): + await self.coordinator.client.set_active_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the active program, new state: %s", + self.entity_id, + self.state, + ) + return + + await self.coordinator.client.set_selected_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the selected program, new state: %s", + self.entity_id, + self.state, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_option", + translation_placeholders=get_dict_from_home_connect_error(err), + ) from err + + @property + def bsh_key(self) -> OptionKey: + """Return the BSH key.""" + return cast(OptionKey, self.entity_description.key) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 6b604fc004e..651c00328b6 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -208,6 +208,39 @@ }, "door-assistant_freezer": { "default": "mdi:door" + }, + "silence_on_demand": { + "default": "mdi:volume-mute", + "state": { + "on": "mdi:volume-mute", + "off": "mdi:volume-high" + } + }, + "half_load": { + "default": "mdi:fraction-one-half" + }, + "hygiene_plus": { + "default": "mdi:silverware-clean" + }, + "eco_dry": { + "default": "mdi:sprout" + }, + "fast_pre_heat": { + "default": "mdi:fire" + }, + "i_dos_1_active": { + "default": "mdi:numeric-1-circle" + }, + "i_dos_2_active": { + "default": "mdi:numeric-2-circle" + } + }, + "time": { + "start_in_relative": { + "default": "mdi:progress-clock" + }, + "finish_in_relative": { + "default": "mdi:progress-clock" } } } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 26c4aa02372..63df33e5432 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -3,7 +3,7 @@ import logging from typing import cast -from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model import GetSetting, OptionKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( @@ -11,6 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) +from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,11 +25,17 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +UNIT_MAP = { + "seconds": UnitOfTime.SECONDS, + "ml": UnitOfVolume.MILLILITERS, + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, +} NUMBERS = ( NumberEntityDescription( @@ -88,6 +95,32 @@ NUMBERS = ( ), ) +NUMBER_OPTIONS = ( + NumberEntityDescription( + key=OptionKey.BSH_COMMON_DURATION, + translation_key="duration", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + translation_key="finish_in_relative", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_START_IN_RELATIVE, + translation_key="start_in_relative", + ), + NumberEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY, + translation_key="fill_quantity", + device_class=NumberDeviceClass.VOLUME, + native_step=1, + ), + NumberEntityDescription( + key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + translation_key="setpoint_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -101,6 +134,18 @@ def _get_entities_for_appliance( ] +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description) + for description in NUMBER_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -111,6 +156,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -184,3 +230,44 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): or not hasattr(self, "_attr_native_step") ): await self.async_fetch_constraints() + + +class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): + """Number option class for Home Connect.""" + + async def async_set_native_value(self, value: float) -> None: + """Set the native value of the entity.""" + await self.async_set_option(value) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_native_value = cast(float | None, self.option_value) + option_definition = self.appliance.options.get(self.bsh_key) + if option_definition: + if option_definition.unit: + candidate_unit = UNIT_MAP.get( + option_definition.unit, option_definition.unit + ) + if ( + not hasattr(self, "_attr_native_unit_of_measurement") + or candidate_unit != self._attr_native_unit_of_measurement + ): + self._attr_native_unit_of_measurement = candidate_unit + self.__dict__.pop("unit_of_measurement", None) + option_constraints = option_definition.constraints + if option_constraints: + if ( + not hasattr(self, "_attr_native_min_value") + or self._attr_native_min_value != option_constraints.min + ) and option_constraints.min: + self._attr_native_min_value = option_constraints.min + if ( + not hasattr(self, "_attr_native_max_value") + or self._attr_native_max_value != option_constraints.max + ) and option_constraints.max: + self._attr_native_max_value = option_constraints.max + if ( + not hasattr(self, "_attr_native_step") + or self._attr_native_step != option_constraints.step_size + ) and option_constraints.step_size: + self._attr_native_step = option_constraints.step_size diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index bc281e3d928..f5298056080 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution @@ -17,17 +17,32 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + BEAN_AMOUNT_OPTIONS, + BEAN_CONTAINER_OPTIONS, + CLEANING_MODE_OPTIONS, + COFFEE_MILK_RATIO_OPTIONS, + COFFEE_TEMPERATURE_OPTIONS, DOMAIN, + DRYING_TARGET_OPTIONS, + FLOW_RATE_OPTIONS, + HOT_WATER_TEMPERATURE_OPTIONS, + INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, + REFERENCE_MAP_ID_OPTIONS, + SPIN_SPEED_OPTIONS, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, + VARIO_PERFECT_OPTIONS, + VENTING_LEVEL_OPTIONS, + WARMING_LEVEL_OPTIONS, ) from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error @@ -44,6 +59,16 @@ class HomeConnectProgramSelectEntityDescription( error_translation_key: str +@dataclass(frozen=True, kw_only=True) +class HomeConnectSelectOptionEntityDescription( + SelectEntityDescription, +): + """Entity Description class for options that have enumeration values.""" + + translation_key_values: dict[str, str] + values_translation_key: dict[str, str] + + PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( HomeConnectProgramSelectEntityDescription( key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, @@ -65,6 +90,159 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ), ) +PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, + translation_key="reference_map_id", + options=list(REFERENCE_MAP_ID_OPTIONS.keys()), + translation_key_values=REFERENCE_MAP_ID_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in REFERENCE_MAP_ID_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + translation_key="reference_map_id", + options=list(CLEANING_MODE_OPTIONS.keys()), + translation_key_values=CLEANING_MODE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in CLEANING_MODE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, + translation_key="bean_amount", + options=list(BEAN_AMOUNT_OPTIONS.keys()), + translation_key_values=BEAN_AMOUNT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_AMOUNT_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, + translation_key="coffee_temperature", + options=list(COFFEE_TEMPERATURE_OPTIONS.keys()), + translation_key_values=COFFEE_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, + translation_key="bean_container", + options=list(BEAN_CONTAINER_OPTIONS.keys()), + translation_key_values=BEAN_CONTAINER_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_CONTAINER_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, + translation_key="flow_rate", + options=list(FLOW_RATE_OPTIONS.keys()), + translation_key_values=FLOW_RATE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, + translation_key="coffee_milk_ratio", + options=list(COFFEE_MILK_RATIO_OPTIONS.keys()), + translation_key_values=COFFEE_MILK_RATIO_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, + translation_key="hot_water_temperature", + options=list(HOT_WATER_TEMPERATURE_OPTIONS.keys()), + translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, + translation_key="drying_target", + options=list(DRYING_TARGET_OPTIONS.keys()), + translation_key_values=DRYING_TARGET_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in DRYING_TARGET_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, + translation_key="venting_level", + options=list(VENTING_LEVEL_OPTIONS.keys()), + translation_key_values=VENTING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VENTING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, + translation_key="intensive_level", + options=list(INTENSIVE_LEVEL_OPTIONS.keys()), + translation_key_values=INTENSIVE_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_OVEN_WARMING_LEVEL, + translation_key="warming_level", + options=list(WARMING_LEVEL_OPTIONS.keys()), + translation_key_values=WARMING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in WARMING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + translation_key="washer_temperature", + options=list(TEMPERATURE_OPTIONS.keys()), + translation_key_values=TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, + translation_key="spin_speed", + options=list(SPIN_SPEED_OPTIONS.keys()), + translation_key_values=SPIN_SPEED_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in SPIN_SPEED_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, + translation_key="vario_perfect", + options=list(VARIO_PERFECT_OPTIONS.keys()), + translation_key_values=VARIO_PERFECT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VARIO_PERFECT_OPTIONS.items() + }, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -81,6 +259,18 @@ def _get_entities_for_appliance( ) +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of entities.""" + return [ + HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS + if desc.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -91,6 +281,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -148,3 +339,53 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err + + +class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): + """Select option class for Home Connect.""" + + entity_description: HomeConnectSelectOptionEntityDescription + _original_option_keys: set[str | None] + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectSelectOptionEntityDescription, + ) -> None: + """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key.keys()) + super().__init__( + coordinator, + appliance, + desc, + ) + + async def async_select_option(self, option: str) -> None: + """Select new option.""" + await self.async_set_option( + self.entity_description.translation_key_values[option] + ) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_current_option = ( + self.entity_description.values_translation_key.get( + cast(str, self.option_value), None + ) + if self.option_value is not None + else None + ) + if ( + (option_definition := self.appliance.options.get(self.bsh_key)) + and (option_constraints := option_definition.constraints) + and option_constraints.allowed_values + and self._original_option_keys != set(option_constraints.allowed_values) + ): + self._original_option_keys = set(option_constraints.allowed_values) + self._attr_options = [ + self.entity_description.values_translation_key[option] + for option in self._original_option_keys + if option is not None + ] + self.__dict__.pop("options", None) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index d9f45c8c31d..88dd017e7d9 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -56,12 +56,6 @@ BSH_PROGRAM_SENSORS = ( "WasherDryer", ), ), - HomeConnectSensorEntityDescription( - key=EventKey.BSH_COMMON_OPTION_DURATION, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - appliance_types=("Oven",), - ), HomeConnectSensorEntityDescription( key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 3ac9f90ba81..8a4dd68530f 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -98,6 +98,9 @@ }, "required_program_or_one_option_at_least": { "message": "A program or at least one of the possible options for a program should be specified" + }, + "set_option": { + "message": "Error setting the option for the program: {error}" } }, "issues": { @@ -859,6 +862,21 @@ }, "washer_i_dos_2_base_level": { "name": "i-Dos 2 base level" + }, + "duration": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]" + }, + "start_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]" + }, + "finish_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]" + }, + "fill_quantity": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]" + }, + "setpoint_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]" } }, "select": { @@ -1179,6 +1197,200 @@ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]", "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } + }, + "reference_map_id": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]" + } + }, + "cleaning_mode": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]" + } + }, + "bean_amount": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]" + } + }, + "coffee_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]" + } + }, + "bean_container": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]", + "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]" + } + }, + "flow_rate": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]" + } + }, + "coffee_milk_ratio": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]" + } + }, + "hot_water_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]" + } + }, + "drying_target": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]", + "state": { + "laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]", + "laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]", + "laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]" + } + }, + "venting_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", + "state": { + "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", + "cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]", + "cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]", + "cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]", + "cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]", + "cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]" + } + }, + "intensive_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]", + "state": { + "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]" + } + }, + "warming_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", + "state": { + "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + } + }, + "washer_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]", + "state": { + "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]", + "laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]", + "laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]", + "laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]", + "laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]", + "laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]", + "laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]", + "laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]", + "laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]", + "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]", + "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]", + "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]", + "laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]" + } + }, + "spin_speed": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", + "state": { + "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + } + }, + "vario_perfect": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", + "state": { + "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", + "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" + } } }, "sensor": { @@ -1365,6 +1577,45 @@ }, "door_assistant_freezer": { "name": "Freezer door assistant" + }, + "multiple_beverages": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]" + }, + "intensiv_zone": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]" + }, + "brilliance_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]" + }, + "vario_speed_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]" + }, + "silence_on_demand": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]" + }, + "half_load": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]" + }, + "extra_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]" + }, + "hygiene_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]" + }, + "eco_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]" + }, + "zeolite_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]" + }, + "fast_pre_heat": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]" + }, + "i_dos1_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]" + }, + "i_dos2_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]" } }, "time": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 7dc375f430d..d5a92eef2a4 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -3,7 +3,7 @@ import logging from typing import Any, cast -from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import EnumerateProgram @@ -37,7 +37,7 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -100,6 +100,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( translation_key="power", ) +SWITCH_OPTIONS = ( + SwitchEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES, + translation_key="multiple_beverages", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE, + translation_key="intensiv_zone", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY, + translation_key="brilliance_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS, + translation_key="vario_speed_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND, + translation_key="silence_on_demand", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + translation_key="half_load", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, + translation_key="extra_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + translation_key="hygiene_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY, + translation_key="eco_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY, + translation_key="zeolite_dry", + ), + SwitchEntityDescription( + key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT, + translation_key="fast_pre_heat", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + translation_key="i_dos1_active", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE, + translation_key="i_dos2_active", + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -123,10 +178,21 @@ def _get_entities_for_appliance( for description in SWITCHES if description.key in appliance.settings ) - return entities +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description) + for description in SWITCH_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -137,6 +203,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -403,3 +470,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None + + +class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity): + """Switch option class for Home Connect.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the option.""" + await self.async_set_option(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the option.""" + await self.async_set_option(False) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_is_on = cast(bool | None, self.option_value) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 7b74c2290c3..e0d60dc8614 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -23,6 +23,8 @@ from aiohomeconnect.model import ( HomeAppliance, Option, Program, + ProgramDefinition, + ProgramKey, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -339,6 +341,29 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.add_events = add_events + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + async def stream_all_events() -> AsyncGenerator[EventMessage]: """Mock stream_all_events.""" while True: @@ -380,6 +405,17 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() + mock.get_available_program = AsyncMock( + return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) + ) + mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.set_active_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) + mock.set_selected_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) mock.side_effect = mock return mock @@ -420,6 +456,11 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) + mock.get_available_program = AsyncMock(side_effect=exception) + mock.get_active_program_options = AsyncMock(side_effect=exception) + mock.get_selected_program_options = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) return mock diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index a357d8fb43e..8f649e5790b 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -124,6 +124,11 @@ "key": "BSH.Common.Setting.ChildLock", "value": false, "type": "Boolean" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 0, + "type": "Integer" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3c73a32d95..512da8bd970 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -272,6 +272,7 @@ 'settings': dict({ 'BSH.Common.Setting.ChildLock': False, 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'LaundryCare.Washer.Setting.IDos2BaseLevel': 0, }), 'status': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py new file mode 100644 index 00000000000..272fc21ba62 --- /dev/null +++ b/tests/components/home_connect/test_entity.py @@ -0,0 +1,299 @@ +"""Tests for Home Connect entity base classes.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, + Event, + EventKey, + EventMessage, + EventType, + Option, + OptionKey, + Program, + ProgramDefinition, + ProgramKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "option_entity_id", + "options_state_stage_1", + "options_availability_stage_2", + "option_without_default", + "option_without_constraints", + ), + [ + ( + "Dishwasher", + { + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: "switch.dishwasher_silence_on_demand", + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: "switch.dishwasher_eco_dry", + }, + [(STATE_ON, True), (STATE_OFF, False), (None, None)], + [False, True, True], + ( + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + "switch.dishwasher_hygiene_plus", + ), + (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + option_entity_id: dict[OptionKey, str], + options_state_stage_1: list[tuple[str, bool | None]], + options_availability_stage_2: list[bool], + option_without_default: tuple[OptionKey, str], + option_without_constraints: tuple[OptionKey, str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + original_get_all_programs_mock = client.get_all_programs.side_effect + options_values = [ + Option( + option_key, + value, + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ] + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + if ha_id != appliance_ha_id: + return await original_get_all_programs_mock(ha_id) + + array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) + return ArrayOfPrograms( + **( + { + "programs": array_of_programs.programs, + array_of_programs_program_arg: Program( + array_of_programs.programs[0].key, options=options_values + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id, (state, _) in zip( + option_entity_id.values(), options_state_stage_1, strict=True + ): + if state is not None: + assert hass.states.is_state(entity_id, state) + else: + assert not hass.states.get(entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + *[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, available in zip( + option_entity_id.keys(), + options_availability_stage_2, + strict=True, + ) + if available + ], + ProgramDefinitionOption( + option_without_default[0], + "Boolean", + constraints=ProgramDefinitionConstraints(), + ), + ProgramDefinitionOption( + option_without_constraints[0], + "Boolean", + ), + ], + ) + ) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + # Verify default values + # Every time the program is updated, the available options should use the default value if existing + for entity_id, available in zip( + option_entity_id.values(), options_availability_stage_2, strict=True + ): + assert hass.states.is_state( + entity_id, STATE_OFF if available else STATE_UNAVAILABLE + ) + for _, entity_id in (option_without_default, option_without_constraints): + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + + +@pytest.mark.parametrize( + ( + "set_active_program_option_side_effect", + "set_selected_program_option_side_effect", + ), + [ + ( + ActiveProgramNotSetError("error.key"), + SelectedProgramNotSetError("error.key"), + ), + ( + HomeConnectError(), + None, + ), + ( + ActiveProgramNotSetError("error.key"), + HomeConnectError(), + ), + ], +) +async def test_option_entity_functionality_exception( + set_active_program_option_side_effect: HomeConnectError | None, + set_selected_program_option_side_effect: HomeConnectError | None, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the option entity handles exceptions correctly.""" + entity_id = "switch.washer_i_dos_1_active" + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + if set_active_program_option_side_effect: + client.set_active_program_option = AsyncMock( + side_effect=set_active_program_option_side_effect + ) + if set_selected_program_option_side_effect: + client.set_selected_program_option = AsyncMock( + side_effect=set_selected_program_option_side_effect + ) + + with pytest.raises(HomeAssistantError, match=r"Error.*setting.*option.*"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index edab86cf819..214dcb6137c 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -7,17 +7,34 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfSettings, + Event, + EventKey, EventMessage, EventType, GetSetting, + OptionKey, + ProgramDefinition, + ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, ATTR_VALUE as SERVICE_ATTR_VALUE, DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, @@ -51,7 +68,6 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) async def test_paired_depaired_devices_flow( appliance_ha_id: str, hass: HomeAssistant, @@ -63,6 +79,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + "Integer", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -369,3 +396,135 @@ async def test_number_entity_error( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + [ + ( + "Oven", + "number.oven_setpoint_temperature", + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + 50, + 260, + 1, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + min: int, + max: int, + step_size: int, + unit: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + unit=unit, + ) + ] + ), + ), + ] + ) + + called_mock = AsyncMock(side_effect=set_program_option_side_effect) + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + setattr(client, called_mock_method, called_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Double", + unit=unit, + constraints=ProgramDefinitionConstraints( + min=min, + max=max, + step_size=step_size, + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit + assert entity_state.attributes[ATTR_MIN] == min + assert entity_state.attributes[ATTR_MAX] == max + assert entity_state.attributes[ATTR_STEP] == step_size + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, SERVICE_ATTR_VALUE: 80}, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": 80, + } + assert hass.states.is_state(entity_id, "80.0") diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index a1e6fafd768..917c092136e 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,7 +1,7 @@ """Tests for home_connect select entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, @@ -10,13 +10,21 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + OptionKey, + ProgramDefinition, ProgramKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) from aiohomeconnect.model.program import ( EnumerateProgram, EnumerateProgramConstraints, Execution, + ProgramDefinitionConstraints, + ProgramDefinitionOption, ) import pytest @@ -70,6 +78,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + "Enumeration", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -413,3 +432,132 @@ async def test_select_exception_handling( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "allowed_values", "expected_options"), + [ + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + None, + { + "laundry_care_washer_enum_type_temperature_cold", + "laundry_care_washer_enum_type_temperature_g_c_20", + "laundry_care_washer_enum_type_temperature_g_c_30", + "laundry_care_washer_enum_type_temperature_g_c_40", + "laundry_care_washer_enum_type_temperature_g_c_50", + "laundry_care_washer_enum_type_temperature_g_c_60", + "laundry_care_washer_enum_type_temperature_g_c_70", + "laundry_care_washer_enum_type_temperature_g_c_80", + "laundry_care_washer_enum_type_temperature_g_c_90", + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ], + { + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + allowed_values: list[str | None] | None, + expected_options: set[str], + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Enumeration", + constraints=ProgramDefinitionConstraints( + allowed_values=allowed_values + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", + } + assert hass.states.is_state( + entity_id, "laundry_care_washer_enum_type_temperature_ul_warm" + ) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index d4e0f999197..1b38809dc05 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -5,17 +5,26 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, ArrayOfSettings, Event, EventKey, EventMessage, + EventType, GetSetting, + OptionKey, + ProgramDefinition, ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError -from aiohomeconnect.model.event import ArrayOfEvents, EventType -from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -81,6 +90,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -840,3 +860,95 @@ async def test_create_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "appliance_ha_id"), + [ + ( + "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "Dishwasher", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, options=[ProgramDefinitionOption(option_key, "Boolean")] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": False, + } + assert hass.states.is_state(entity_id, STATE_OFF) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": True, + } + assert hass.states.is_state(entity_id, STATE_ON) From 98c6a578b7da32fb4da67c37693244f73311aed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Feb 2025 21:14:11 +0100 Subject: [PATCH 1138/1435] Add buttons to Home Connect (#138792) * Add buttons * Fix stale documentation --- .../components/home_connect/__init__.py | 1 + .../components/home_connect/button.py | 160 +++++++++ .../components/home_connect/coordinator.py | 14 + .../components/home_connect/strings.json | 17 + tests/components/home_connect/conftest.py | 18 + .../fixtures/available_commands.json | 142 ++++++++ tests/components/home_connect/test_button.py | 315 ++++++++++++++++++ 7 files changed, 667 insertions(+) create mode 100644 homeassistant/components/home_connect/button.py create mode 100644 tests/components/home_connect/fixtures/available_commands.json create mode 100644 tests/components/home_connect/test_button.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index b4ceb11be92..637fd7aa3a8 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -187,6 +187,7 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py new file mode 100644 index 00000000000..138979409a5 --- /dev/null +++ b/homeassistant/components/home_connect/button.py @@ -0,0 +1,160 @@ +"""Provides button entities for Home Connect.""" + +from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model.error import HomeConnectError + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import setup_home_connect_entry +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error + + +class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): + """Describes Home Connect button entity.""" + + key: CommandKey + + +COMMAND_BUTTONS = ( + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_OPEN_DOOR, + translation_key="open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PARTLY_OPEN_DOOR, + translation_key="partly_open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PAUSE_PROGRAM, + translation_key="pause_program", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_RESUME_PROGRAM, + translation_key="resume_program", + ), +) + + +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectEntity] = [] + entities.extend( + HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description) + for description in COMMAND_BUTTONS + if description.key in appliance.commands + ) + if appliance.info.type in APPLIANCES_WITH_PROGRAMS: + entities.append( + HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance) + ) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Home Connect button entities.""" + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, + ) + + +class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): + """Describes Home Connect button entity.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: ButtonEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + # The entity is subscribed to the appliance connected event, + # but it will receive also the disconnected event + ButtonEntityDescription( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + ), + ) + self.entity_description = desc + self.appliance = appliance + self.unique_id = f"{appliance.info.ha_id}-{desc.key}" + + def update_native_value(self) -> None: + """Set the value of the entity.""" + + +class HomeConnectCommandButtonEntity(HomeConnectButtonEntity): + """Button entity for Home Connect commands.""" + + entity_description: HomeConnectCommandButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.put_command( + self.appliance.info.ha_id, + command_key=self.entity_description.key, + value=True, + ) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(error), + "command": self.entity_description.key, + }, + ) from error + + +class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity): + """Button entity for stopping a program.""" + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + ButtonEntityDescription( + key="StopProgram", + translation_key="stop_program", + ), + ) + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.stop_program(self.appliance.info.ha_id) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stop_program", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index b5f0f711597..80ae8173d86 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + CommandKey, Event, EventKey, EventMessage, @@ -53,6 +54,7 @@ EVENT_STREAM_RECONNECT_DELAY = 30 class HomeConnectApplianceData: """Class to hold Home Connect appliance data.""" + commands: set[CommandKey] events: dict[EventKey, Event] info: HomeAppliance options: dict[OptionKey, ProgramDefinitionOption] @@ -62,6 +64,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected self.options.clear() @@ -408,7 +411,18 @@ class HomeConnectCoordinator( unit=option.unit, ) + try: + commands = { + command.key + for command in ( + await self.client.get_available_commands(appliance.ha_id) + ).commands + } + except HomeConnectError: + commands = set() + appliance_data = HomeConnectApplianceData( + commands=commands, events=events, info=appliance, options=options, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8a4dd68530f..db53e76fb95 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -815,6 +815,23 @@ "name": "Wine compartment door" } }, + "button": { + "open_door": { + "name": "Open door" + }, + "partly_open_door": { + "name": "Partly open door" + }, + "pause_program": { + "name": "Pause program" + }, + "resume_program": { + "name": "Resume program" + }, + "stop_program": { + "name": "Stop program" + } + }, "light": { "cooking_lighting": { "name": "Functional light" diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index e0d60dc8614..49cbc89ba41 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + ArrayOfCommands, ArrayOfEvents, ArrayOfHomeAppliances, ArrayOfOptions, @@ -50,6 +51,9 @@ MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings. MOCK_STATUS = ArrayOfStatus.from_dict( load_json_object_fixture("home_connect/status.json")["data"] ) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) CLIENT_ID = "1234" @@ -326,6 +330,14 @@ async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): raise HomeConnectApiError("error.key", "error description") +async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) + raise HomeConnectApiError("error.key", "error description") + + @pytest.fixture(name="client") def mock_client(request: pytest.FixtureRequest) -> MagicMock: """Fixture to mock Client from HomeConnect.""" @@ -385,6 +397,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM ), ) + mock.stop_program = AsyncMock() mock.set_active_program_option = AsyncMock( side_effect=_get_set_program_options_side_effect(event_queue), ) @@ -404,6 +417,9 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) + mock.get_available_commands = AsyncMock( + side_effect=_get_available_commands_side_effect + ) mock.put_command = AsyncMock() mock.get_available_program = AsyncMock( return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) @@ -446,6 +462,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.start_program = AsyncMock(side_effect=exception) mock.stop_program = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) mock.set_active_program_options = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) @@ -455,6 +472,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=exception) mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) + mock.get_available_commands = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) mock.get_available_program = AsyncMock(side_effect=exception) mock.get_active_program_options = AsyncMock(side_effect=exception) diff --git a/tests/components/home_connect/fixtures/available_commands.json b/tests/components/home_connect/fixtures/available_commands.json new file mode 100644 index 00000000000..e4ed6c21b7c --- /dev/null +++ b/tests/components/home_connect/fixtures/available_commands.json @@ -0,0 +1,142 @@ +{ + "Cooktop": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Hood": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Oven": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + }, + { + "key": "BSH.Common.Command.PartlyOpenDoor", + "name": "Partly open door" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "CleaningRobot": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dishwasher": { + "commands": [ + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Washer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "WasherDryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Freezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "FridgeFreezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "Refrigerator": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + } +} diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py new file mode 100644 index 00000000000..5af7e40ca43 --- /dev/null +++ b/tests/components/home_connect/test_button.py @@ -0,0 +1,315 @@ +"""Tests for home_connect button entities.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model.command import Command +from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + +async def test_buttons( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test button entities.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_available_commands_original_mock = client.get_available_commands + get_available_programs_mock = client.get_available_programs + + async def get_available_commands_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_commands_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_available_commands = get_available_commands_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +async def test_button_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_ids = [ + "button.washer_pause_program", + "button.washer_stop_program", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method_call", "expected_kwargs"), + [ + ( + "button.washer_pause_program", + "put_command", + {"command_key": CommandKey.BSH_COMMON_PAUSE_PROGRAM, "value": True}, + ), + ("button.washer_stop_program", "stop_program", {}), + ], +) +async def test_button_functionality( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_id: str, + method_call: str, + expected_kwargs: dict[str, Any], + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + + +async def test_command_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_pause_program" + + client_with_exception.get_available_commands = AsyncMock( + return_value=ArrayOfCommands( + [ + Command( + CommandKey.BSH_COMMON_PAUSE_PROGRAM, + "Pause Program", + ) + ] + ) + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*executing.*command"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_stop_program_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_stop_program" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*stop.*program"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From 93b01a3bc39d8ad079ee500196af0e09c9e6814a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 14:39:12 -0600 Subject: [PATCH 1139/1435] Fix minimum schema version to run event_id_post_migration (#139014) * Fix minimum version to run event_id_post_migration The table rebuild to fix the foreign key constraint was added in https://github.com/home-assistant/core/pull/120779 but the schema version was not bumped so we need to make sure any database that was created with schema 43 or older still has the migration run as otherwise they will not be able to purge the database with SQLite since each delete in the events table will due a full table scan of the states table to look for a foreign key that is not there fixes #138818 * Apply suggestions from code review * Update homeassistant/components/recorder/migration.py * Update homeassistant/components/recorder/migration.py * Update homeassistant/components/recorder/const.py * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * update tests, add more cover * update tests, add more cover * Update tests/components/recorder/test_migration_run_time_migrations_remember.py --- homeassistant/components/recorder/const.py | 5 ++ .../components/recorder/migration.py | 13 +++++- .../recorder/test_migration_from_schema_32.py | 15 ++++-- ..._migration_run_time_migrations_remember.py | 46 +++++++++++++++++-- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index c91845e8436..b7ee984558c 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -50,6 +50,11 @@ STATES_META_SCHEMA_VERSION = 38 LAST_REPORTED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 +# https://github.com/home-assistant/core/pull/120779 +# fixed the foreign keys in the states table but it did +# not bump the schema version which means only databases +# created with schema 44 and later do not need the rebuild. INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c6cdd6d317f..3aa12f2b1f9 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -52,6 +52,7 @@ from .auto_repairs.statistics.schema import ( from .const import ( CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, EVENT_TYPE_IDS_SCHEMA_VERSION, + LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION, LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, STATES_META_SCHEMA_VERSION, SupportedDialect, @@ -2490,9 +2491,10 @@ class BaseMigration(ABC): if self.initial_schema_version > self.max_initial_schema_version: _LOGGER.debug( "Data migration '%s' not needed, database created with version %s " - "after migrator was added", + "after migrator was added in version %s", self.migration_id, self.initial_schema_version, + self.max_initial_schema_version, ) return False if self.start_schema_version < self.required_schema_version: @@ -2868,7 +2870,14 @@ class EventIDPostMigration(BaseRunTimeMigration): """Migration to remove old event_id index from states.""" migration_id = "event_id_post_migration" - max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1 + # Note we don't subtract 1 from the max_initial_schema_version + # in this case because we need to run this migration on databases + # version >= 43 because the schema was not bumped when the table + # rebuild was added in + # https://github.com/home-assistant/core/pull/120779 + # which means its only safe to assume version 44 and later + # do not need the table rebuild + max_initial_schema_version = LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION task = MigrationTask migration_version = 2 diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 0a5f5d4da73..012e227c11a 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -225,6 +225,7 @@ async def test_migrate_events_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventsContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -282,6 +283,7 @@ async def test_migrate_events_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -588,6 +590,7 @@ async def test_migrate_states_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.StatesContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -640,6 +643,7 @@ async def test_migrate_states_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -1127,6 +1131,7 @@ async def test_post_migrate_entity_ids( patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EntityIDMigration, "migrate_data"), patch.object(migration.EntityIDPostMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -1158,9 +1163,12 @@ async def test_post_migrate_entity_ids( return {state.state: state.entity_id for state in states} # Run again with new schema, let migration run - with patch( - "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create - ) as wrapped_idx_create: + with ( + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), + ): async with ( async_test_home_assistant() as hass, async_test_recorder(hass) as instance, @@ -1169,7 +1177,6 @@ async def test_post_migrate_entity_ids( await hass.async_block_till_done() await async_wait_recording_done(hass) - await async_wait_recording_done(hass) states_by_state = await instance.async_add_executor_job( _fetch_migrated_states diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 43a1b028348..350126b4c72 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -115,7 +115,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 1), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, [ @@ -131,7 +131,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], @@ -143,13 +143,43 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (0, 0), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], ), ( 38, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 43, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + # Schema was not bumped when the SQLite + # table rebuild was implemented so we need + # run event_id_post_migration up until + # schema 44 since its the first one we can + # be sure has the foreign key constraint was removed + # via https://github.com/home-assistant/core/pull/120779 + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 44, { "state_context_id_as_binary": (0, 0), "event_context_id_as_binary": (0, 0), @@ -266,8 +296,14 @@ async def test_data_migrator_logic( # the expected number of times. for migrator, mock in migrator_mocks.items(): needs_migrate_calls, migrate_data_calls = expected_migrator_calls[migrator] - assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls - assert len(mock["migrate_data"].mock_calls) == migrate_data_calls + assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls, ( + f"Expected {migrator} needs_migrate to be called {needs_migrate_calls} times," + f" got {len(mock['needs_migrate'].mock_calls)}" + ) + assert len(mock["migrate_data"].mock_calls) == migrate_data_calls, ( + f"Expected {migrator} migrate_data to be called {migrate_data_calls} times, " + f"got {len(mock['migrate_data'].mock_calls)}" + ) @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) From d821aa91626845d2f33e3fdf463edbd6c0697387 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Sun, 23 Feb 2025 05:51:54 +0900 Subject: [PATCH 1140/1435] Fix dryer's remaining time issue (#138764) Fix dryer's remain_time issue Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/sensor.py | 48 ++++++++++++--------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 95198d931a1..754b07cb2db 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -581,36 +581,44 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): local_now = datetime.now( tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) ) - if value in [0, None, time.min]: - # Reset to None + self._device_state = ( + self.coordinator.data[self._device_state_id].value + if self._device_state_id in self.coordinator.data + else None + ) + if value in [0, None, time.min] or ( + self._device_state == "power_off" + and self.entity_description.key + in [TimerProperty.REMAIN, TimerProperty.TOTAL] + ): + # Reset to None when power_off value = None elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: if self.entity_description.key in TIME_SENSOR_DESC: - # Set timestamp for time + # Set timestamp for absolute time value = local_now.replace(hour=value.hour, minute=value.minute) else: # Set timestamp for delta - new_state = ( - self.coordinator.data[self._device_state_id].value - if self._device_state_id in self.coordinator.data - else None - ) - if ( - self.native_value is not None - and self._device_state == new_state - ): - # Skip update when same state - return - - self._device_state = new_state - time_delta = timedelta( + event_data = timedelta( hours=value.hour, minutes=value.minute, seconds=value.second ) - value = ( - (local_now - time_delta) + new_time = ( + (local_now - event_data) if self.entity_description.key == TimerProperty.RUNNING - else (local_now + time_delta) + else (local_now + event_data) ) + # The remain_time may change during the wash/dry operation depending on various reasons. + # If there is a diff of more than 60sec, the new timestamp is used + if ( + parse_native_value := dt_util.parse_datetime( + str(self.native_value) + ) + ) is None or abs(new_time - parse_native_value) > timedelta( + seconds=60 + ): + value = new_time + else: + value = self.native_value elif self.entity_description.device_class == SensorDeviceClass.DURATION: # Set duration value = self._get_duration( From 5a0a3d27d9098c3d572a430c4907bd319930b263 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 15:11:28 -0600 Subject: [PATCH 1141/1435] Bump aiodiscover to 2.6.1 (#139055) changelog: https://github.com/Bluetooth-Devices/aiodiscover/compare/v2.6.0...v2.6.1 --- 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 382a9b94ff7..65d43f80abe 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "aiodhcpwatcher==1.1.1", - "aiodiscover==2.6.0", + "aiodiscover==2.6.1", "cached-ipaddress==0.8.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 40f7e511332..967ce98a705 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ # Automatically generated by gen_requirements_all.py, do not edit aiodhcpwatcher==1.1.1 -aiodiscover==2.6.0 +aiodiscover==2.6.1 aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0ffd8b7e781..ab0a714e296 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,7 +219,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.6.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d070883303..5b03f3e9197 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -207,7 +207,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.6.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 From 17c1c0e1553fab9edd0691d35913d184c4bf6b35 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:35:32 -0600 Subject: [PATCH 1142/1435] Remove unnecessary debug message from vesync (#139083) Remove unnecessary debug write --- homeassistant/components/vesync/binary_sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py index 620222e4d2f..7b6f14e04dc 100644 --- a/homeassistant/components/vesync/binary_sensor.py +++ b/homeassistant/components/vesync/binary_sensor.py @@ -102,5 +102,4 @@ class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - _LOGGER.debug(rgetattr(self.device, self.entity_description.key)) return self.entity_description.is_on(self.device) From b1b65e4d568514c63dd5af6936404ac0d876bf8b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:59:51 +0100 Subject: [PATCH 1143/1435] Bump py-synologydsm-api to 2.7.0 (#139082) bump py-synologydsm-api to 2.7.0 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index d076d843c36..dc5634e7a84 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.6.3"], + "requirements": ["py-synologydsm-api==2.7.0"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index ab0a714e296..d55aec73653 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1749,7 +1749,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.3 +py-synologydsm-api==2.7.0 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b03f3e9197..f751c87ace6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1447,7 +1447,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.3 +py-synologydsm-api==2.7.0 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 5b0eca7f8578c6e40154a00780d52613c1ffb453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 01:42:25 +0100 Subject: [PATCH 1144/1435] Add select setting entities to Home Connect (#138884) * Add select setting entities * Improvements --- .../components/home_connect/const.py | 4 +- .../components/home_connect/select.py | 225 +++++++++++++----- .../components/home_connect/strings.json | 26 ++ .../home_connect/fixtures/settings.json | 11 +- .../snapshots/test_diagnostics.ambr | 2 +- tests/components/home_connect/test_select.py | 130 ++++++++++ 6 files changed, 340 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 3a22297ebee..692a5e91851 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -87,7 +87,7 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() } -REFERENCE_MAP_ID_OPTIONS = { +AVAILABLE_MAPS_ENUM = { bsh_key_to_translation_key(option): option for option in ( "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap", @@ -305,7 +305,7 @@ PROGRAM_ENUM_OPTIONS = { for option_key, options in ( ( OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, - REFERENCE_MAP_ID_OPTIONS, + AVAILABLE_MAPS_ENUM, ), ( OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index f5298056080..e4d50b0d5e9 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import EventKey, OptionKey, ProgramKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution @@ -17,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + AVAILABLE_MAPS_ENUM, BEAN_AMOUNT_OPTIONS, BEAN_CONTAINER_OPTIONS, CLEANING_MODE_OPTIONS, @@ -28,9 +29,12 @@ from .const import ( HOT_WATER_TEMPERATURE_OPTIONS, INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, - REFERENCE_MAP_ID_OPTIONS, SPIN_SPEED_OPTIONS, + SVE_TRANSLATION_KEY_SET_SETTING, + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_VALUE, TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, VARIO_PERFECT_OPTIONS, @@ -43,7 +47,30 @@ from .coordinator import ( HomeConnectCoordinator, ) from .entity import HomeConnectEntity, HomeConnectOptionEntity -from .utils import get_dict_from_home_connect_error +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.ColorTemperature.custom", + "Cooking.Hood.EnumType.ColorTemperature.warm", + "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "Cooking.Hood.EnumType.ColorTemperature.neutralToCold", + "Cooking.Hood.EnumType.ColorTemperature.cold", + ) +} + +AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM = { + **{ + bsh_key_to_translation_key(option): option + for option in ("BSH.Common.EnumType.AmbientLightColor.CustomColor",) + }, + **{ + str(option): f"BSH.Common.EnumType.AmbientLightColor.Color{option}" + for option in range(1, 100) + }, +} @dataclass(frozen=True, kw_only=True) @@ -60,10 +87,8 @@ class HomeConnectProgramSelectEntityDescription( @dataclass(frozen=True, kw_only=True) -class HomeConnectSelectOptionEntityDescription( - SelectEntityDescription, -): - """Entity Description class for options that have enumeration values.""" +class HomeConnectSelectEntityDescription(SelectEntityDescription): + """Entity Description class for settings and options that have enumeration values.""" translation_key_values: dict[str, str] values_translation_key: dict[str, str] @@ -90,151 +115,184 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ), ) -PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( - HomeConnectSelectOptionEntityDescription( - key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, - translation_key="reference_map_id", - options=list(REFERENCE_MAP_ID_OPTIONS.keys()), - translation_key_values=REFERENCE_MAP_ID_OPTIONS, +SELECT_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectEntityDescription( + key=SettingKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CURRENT_MAP, + translation_key="current_map", + options=list(AVAILABLE_MAPS_ENUM), + translation_key_values=AVAILABLE_MAPS_ENUM, values_translation_key={ value: translation_key - for translation_key, value in REFERENCE_MAP_ID_OPTIONS.items() + for translation_key, value in AVAILABLE_MAPS_ENUM.items() }, ), - HomeConnectSelectOptionEntityDescription( - key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + HomeConnectSelectEntityDescription( + key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + translation_key="functional_light_color_temperature", + options=list(FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM), + translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items() + }, + ), + HomeConnectSelectEntityDescription( + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + translation_key="ambient_light_color", + options=list(AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM), + translation_key_values=AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM.items() + }, + ), +) + +PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, translation_key="reference_map_id", - options=list(CLEANING_MODE_OPTIONS.keys()), + options=list(AVAILABLE_MAPS_ENUM), + translation_key_values=AVAILABLE_MAPS_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in AVAILABLE_MAPS_ENUM.items() + }, + ), + HomeConnectSelectEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + translation_key="cleaning_mode", + options=list(CLEANING_MODE_OPTIONS), translation_key_values=CLEANING_MODE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in CLEANING_MODE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, translation_key="bean_amount", - options=list(BEAN_AMOUNT_OPTIONS.keys()), + options=list(BEAN_AMOUNT_OPTIONS), translation_key_values=BEAN_AMOUNT_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in BEAN_AMOUNT_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, translation_key="coffee_temperature", - options=list(COFFEE_TEMPERATURE_OPTIONS.keys()), + options=list(COFFEE_TEMPERATURE_OPTIONS), translation_key_values=COFFEE_TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, translation_key="bean_container", - options=list(BEAN_CONTAINER_OPTIONS.keys()), + options=list(BEAN_CONTAINER_OPTIONS), translation_key_values=BEAN_CONTAINER_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in BEAN_CONTAINER_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, translation_key="flow_rate", - options=list(FLOW_RATE_OPTIONS.keys()), + options=list(FLOW_RATE_OPTIONS), translation_key_values=FLOW_RATE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in FLOW_RATE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, translation_key="coffee_milk_ratio", - options=list(COFFEE_MILK_RATIO_OPTIONS.keys()), + options=list(COFFEE_MILK_RATIO_OPTIONS), translation_key_values=COFFEE_MILK_RATIO_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in FLOW_RATE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, translation_key="hot_water_temperature", - options=list(HOT_WATER_TEMPERATURE_OPTIONS.keys()), + options=list(HOT_WATER_TEMPERATURE_OPTIONS), translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, translation_key="drying_target", - options=list(DRYING_TARGET_OPTIONS.keys()), + options=list(DRYING_TARGET_OPTIONS), translation_key_values=DRYING_TARGET_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in DRYING_TARGET_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, translation_key="venting_level", - options=list(VENTING_LEVEL_OPTIONS.keys()), + options=list(VENTING_LEVEL_OPTIONS), translation_key_values=VENTING_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in VENTING_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, translation_key="intensive_level", - options=list(INTENSIVE_LEVEL_OPTIONS.keys()), + options=list(INTENSIVE_LEVEL_OPTIONS), translation_key_values=INTENSIVE_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_OVEN_WARMING_LEVEL, translation_key="warming_level", - options=list(WARMING_LEVEL_OPTIONS.keys()), + options=list(WARMING_LEVEL_OPTIONS), translation_key_values=WARMING_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in WARMING_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, translation_key="washer_temperature", - options=list(TEMPERATURE_OPTIONS.keys()), + options=list(TEMPERATURE_OPTIONS), translation_key_values=TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, translation_key="spin_speed", - options=list(SPIN_SPEED_OPTIONS.keys()), + options=list(SPIN_SPEED_OPTIONS), translation_key_values=SPIN_SPEED_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in SPIN_SPEED_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, translation_key="vario_perfect", - options=list(VARIO_PERFECT_OPTIONS.keys()), + options=list(VARIO_PERFECT_OPTIONS), translation_key_values=VARIO_PERFECT_OPTIONS, values_translation_key={ value: translation_key @@ -249,14 +307,21 @@ def _get_entities_for_appliance( appliance: HomeConnectApplianceData, ) -> list[HomeConnectEntity]: """Get a list of entities.""" - return ( - [ - HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ] - if appliance.info.type in APPLIANCES_WITH_PROGRAMS - else [] - ) + return [ + *( + [ + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + ] + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + else [] + ), + *[ + HomeConnectSelectEntity(entry.runtime_data, appliance, desc) + for desc in SELECT_ENTITY_DESCRIPTIONS + if desc.key in appliance.settings + ], + ] def _get_option_entities_for_appliance( @@ -341,17 +406,71 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): ) from err +class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): + """Select setting class for Home Connect.""" + + entity_description: HomeConnectSelectEntityDescription + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectSelectEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + desc, + ) + setting = appliance.settings.get(cast(SettingKey, desc.key)) + if setting and setting.constraints and setting.constraints.allowed_values: + self._attr_options = [ + desc.values_translation_key[option] + for option in setting.constraints.allowed_values + if option in desc.values_translation_key + ] + + async def async_select_option(self, option: str) -> None: + """Select new option.""" + value = self.entity_description.translation_key_values[option] + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + value=value, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: value, + }, + ) from err + + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_current_option = self.entity_description.values_translation_key.get( + data.value + ) + + class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): """Select option class for Home Connect.""" - entity_description: HomeConnectSelectOptionEntityDescription + entity_description: HomeConnectSelectEntityDescription _original_option_keys: set[str | None] def __init__( self, coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, - desc: HomeConnectSelectOptionEntityDescription, + desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" self._original_option_keys = set(desc.values_translation_key.keys()) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index db53e76fb95..dde002d1caa 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1215,6 +1215,32 @@ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } }, + "current_map": { + "name": "Current map", + "state": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]" + } + }, + "functional_light_color_temperature": { + "name": "Functional light color temperature", + "state": { + "cooking_hood_enum_type_color_temperature_custom": "Custom", + "cooking_hood_enum_type_color_temperature_warm": "Warm", + "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral", + "cooking_hood_enum_type_color_temperature_neutral": "Neutral", + "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold", + "cooking_hood_enum_type_color_temperature_cold": "Cold" + } + }, + "ambient_light_color": { + "name": "Ambient light color", + "state": { + "b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom" + } + }, "reference_map_id": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", "state": { diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 8f649e5790b..bd1bea18365 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -68,9 +68,16 @@ "type": "Double" }, { - "key": "BSH.Common.Setting.ColorTemperature", + "key": "Cooking.Hood.Setting.ColorTemperature", "value": "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral", - "type": "BSH.Common.EnumType.ColorTemperature" + "type": "BSH.Common.EnumType.ColorTemperature", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.ColorTemperature.warm", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "Cooking.Hood.EnumType.ColorTemperature.cold" + ] + } }, { "key": "BSH.Common.Setting.AmbientLightEnabled", diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index 512da8bd970..28f45ce97ba 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -98,8 +98,8 @@ 'BSH.Common.Setting.AmbientLightEnabled': True, 'Cooking.Common.Setting.Lighting': True, 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperature': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, - 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), 'status': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 917c092136e..d98dbd8e5f6 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -6,13 +6,16 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfPrograms, + ArrayOfSettings, Event, EventKey, EventMessage, EventType, + GetSetting, OptionKey, ProgramDefinition, ProgramKey, + SettingKey, ) from aiohomeconnect.model.error import ( ActiveProgramNotSetError, @@ -26,6 +29,7 @@ from aiohomeconnect.model.program import ( ProgramDefinitionConstraints, ProgramDefinitionOption, ) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components.home_connect.const import DOMAIN @@ -434,6 +438,132 @@ async def test_select_exception_handling( assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "expected_options", + "value_to_set", + "expected_value_call_arg", + ), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + { + "cooking_hood_enum_type_color_temperature_warm", + "cooking_hood_enum_type_color_temperature_neutral", + "cooking_hood_enum_type_color_temperature_cold", + }, + "cooking_hood_enum_type_color_temperature_neutral", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *[str(i) for i in range(1, 100)], + }, + "42", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ), + ], +) +async def test_select_functionality( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + expected_options: set[str], + value_to_set: str, + expected_value_call_arg: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test select functionality.""" + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + ) + await hass.async_block_till_done() + + client.set_setting.assert_called_once() + assert client.set_setting.call_args.args == (appliance_ha_id,) + assert client.set_setting.call_args.kwargs == { + "setting_key": setting_key, + "value": expected_value_call_arg, + } + assert hass.states.is_state(entity_id, value_to_set) + + +@pytest.mark.parametrize( + ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "cooking_hood_enum_type_color_temperature_neutral", + "set_setting", + ), + ], +) +async def test_select_entity_error( + entity_id: str, + setting_key: SettingKey, + allowed_value: str, + value_to_set: str, + mock_attr: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test select entity error.""" + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value_to_set, + constraints=SettingConstraints(allowed_values=[allowed_value]), + ) + ] + ) + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(HomeConnectError): + await getattr(client_with_exception, mock_attr)() + + with pytest.raises( + HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + blocking=True, + ) + assert getattr(client_with_exception, mock_attr).call_count == 2 + + @pytest.mark.parametrize( ( "set_active_program_options_side_effect", From 8ce2727447c8b0c3b79c4a5ac0cdac1ca0db2828 Mon Sep 17 00:00:00 2001 From: javers99 <90975080+javers99@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:45:44 +0000 Subject: [PATCH 1145/1435] Fix typo in SSH connection string for cisco ios device_tracker (#138584) Update device_tracker.py Typo in "uft-8" -> pxssh.pxssh(encoding="utf-8") --- homeassistant/components/cisco_ios/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 0477ebb111c..6cc403817cf 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -104,7 +104,7 @@ class CiscoDeviceScanner(DeviceScanner): """Open connection to the router and get arp entries.""" try: - cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8") + cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="utf-8") cisco_ssh.login( self.host, self.username, From 0797c3228b513086ab98e48d2cfc3a09bbd4b4ca Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 23 Feb 2025 08:35:00 +0000 Subject: [PATCH 1146/1435] Bump pyprosegur to 0.0.14 (#139077) bump pyprosegur --- homeassistant/components/prosegur/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index 6419b81aa7f..2e649ebd5bd 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.13"] + "requirements": ["pyprosegur==0.0.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index d55aec73653..ef4360a2061 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.13 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f751c87ace6..b78b82d8f2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1816,7 +1816,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.13 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 From 91668e99e326fcdf8dec20a3faa7f8640d7005bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Feb 2025 04:51:25 -0500 Subject: [PATCH 1147/1435] OpenAI to report when running out of funds (#139088) --- .../openai_conversation/conversation.py | 3 ++ .../openai_conversation/test_conversation.py | 31 +++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index fddabb740ac..cc09ec77c0e 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -287,6 +287,9 @@ class OpenAIConversationEntity( try: result = await client.chat.completions.create(**model_args) + except openai.RateLimitError as err: + LOGGER.error("Rate limited by OpenAI: %s", err) + raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 2c956b7e63f..238fd5f2d7b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch from httpx import Response -from openai import RateLimitError +from openai import AuthenticationError, RateLimitError from openai.types.chat.chat_completion_chunk import ( ChatCompletionChunk, Choice, @@ -94,23 +94,42 @@ async def test_entity( ) +@pytest.mark.parametrize( + ("exception", "message"), + [ + ( + RateLimitError( + response=Response(status_code=429, request=""), body=None, message=None + ), + "Rate limited or insufficient funds", + ), + ( + AuthenticationError( + response=Response(status_code=401, request=""), body=None, message=None + ), + "Error talking to OpenAI", + ), + ], +) async def test_error_handling( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + exception, + message, ) -> None: """Test that we handle errors when calling completion API.""" with patch( "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock, - side_effect=RateLimitError( - response=Response(status_code=None, request=""), body=None, message=None - ), + side_effect=exception, ): result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result + assert result.response.speech["plain"]["speech"] == message, result.response.speech async def test_conversation_agent( From 746d1800f98021d0cab182af0d75c6d5081dad9b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 23 Feb 2025 11:43:25 +0000 Subject: [PATCH 1148/1435] Add tests to Evohome for its native services (#139104) initial commit --- homeassistant/components/evohome/__init__.py | 20 +- homeassistant/components/evohome/climate.py | 21 +-- homeassistant/components/evohome/const.py | 7 +- tests/components/evohome/test_evo_services.py | 177 ++++++++++++++++++ tests/components/evohome/test_init.py | 42 +---- 5 files changed, 202 insertions(+), 65 deletions(-) create mode 100644 tests/components/evohome/test_evo_services.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index e322e266b8a..9dce352df30 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -25,6 +25,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_MODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, @@ -40,11 +41,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import ( - ATTR_DURATION_DAYS, - ATTR_DURATION_HOURS, + ATTR_DURATION, ATTR_DURATION_UNTIL, - ATTR_SYSTEM_MODE, - ATTR_ZONE_TEMP, + ATTR_PERIOD, + ATTR_SETPOINT, CONF_LOCATION_IDX, DOMAIN, SCAN_INTERVAL_DEFAULT, @@ -81,7 +81,7 @@ RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_ZONE_TEMP): vol.All( + vol.Required(ATTR_SETPOINT): vol.All( vol.Coerce(float), vol.Range(min=4.0, max=35.0) ), vol.Optional(ATTR_DURATION_UNTIL): vol.All( @@ -222,7 +222,7 @@ def setup_service_functions( # Permanent-only modes will use this schema perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] if perm_modes: # any of: "Auto", "HeatingOff": permanent only - schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) + schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)}) system_mode_schemas.append(schema) modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] @@ -232,8 +232,8 @@ def setup_service_functions( if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { - vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION_HOURS): vol.All( + vol.Required(ATTR_MODE): vol.In(temp_modes), + vol.Optional(ATTR_DURATION): vol.All( cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), ), @@ -246,8 +246,8 @@ def setup_service_functions( if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { - vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION_DAYS): vol.All( + vol.Required(ATTR_MODE): vol.In(temp_modes), + vol.Optional(ATTR_PERIOD): vol.All( cv.time_period, vol.Range(min=timedelta(days=1), max=timedelta(days=99)), ), diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8a455b300f8..b44dc9791b0 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -29,7 +29,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature +from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,11 +38,10 @@ from homeassistant.util import dt as dt_util from . import EVOHOME_KEY from .const import ( - ATTR_DURATION_DAYS, - ATTR_DURATION_HOURS, + ATTR_DURATION, ATTR_DURATION_UNTIL, - ATTR_SYSTEM_MODE, - ATTR_ZONE_TEMP, + ATTR_PERIOD, + ATTR_SETPOINT, EvoService, ) from .coordinator import EvoDataUpdateCoordinator @@ -180,7 +179,7 @@ class EvoZone(EvoChild, EvoClimateEntity): return # otherwise it is EvoService.SET_ZONE_OVERRIDE - temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) + temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: duration: timedelta = data[ATTR_DURATION_UNTIL] @@ -349,16 +348,16 @@ class EvoController(EvoClimateEntity): Data validation is not required, it will have been done upstream. """ if service == EvoService.SET_SYSTEM_MODE: - mode = data[ATTR_SYSTEM_MODE] + mode = data[ATTR_MODE] else: # otherwise it is EvoService.RESET_SYSTEM mode = EvoSystemMode.AUTO_WITH_RESET - if ATTR_DURATION_DAYS in data: + if ATTR_PERIOD in data: until = dt_util.start_of_local_day() - until += data[ATTR_DURATION_DAYS] + until += data[ATTR_PERIOD] - elif ATTR_DURATION_HOURS in data: - until = dt_util.now() + data[ATTR_DURATION_HOURS] + elif ATTR_DURATION in data: + until = dt_util.now() + data[ATTR_DURATION] else: until = None diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 12642addfa4..9da5969df1e 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -18,11 +18,10 @@ USER_DATA: Final = "user_data" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) -ATTR_SYSTEM_MODE: Final = "mode" -ATTR_DURATION_DAYS: Final = "period" -ATTR_DURATION_HOURS: Final = "duration" +ATTR_PERIOD: Final = "period" # number of days +ATTR_DURATION: Final = "duration" # number of minutes, <24h -ATTR_ZONE_TEMP: Final = "setpoint" +ATTR_SETPOINT: Final = "setpoint" ATTR_DURATION_UNTIL: Final = "duration" diff --git a/tests/components/evohome/test_evo_services.py b/tests/components/evohome/test_evo_services.py new file mode 100644 index 00000000000..c9f20aecd4f --- /dev/null +++ b/tests/components/evohome/test_evo_services.py @@ -0,0 +1,177 @@ +"""The tests for the native services of Evohome.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome.const import ( + ATTR_DURATION, + ATTR_PERIOD, + ATTR_SETPOINT, + DOMAIN, + EvoService, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_refresh_system( + hass: HomeAssistant, + evohome: EvohomeClient, +) -> None: + """Test Evohome's refresh_system service (for all temperature control systems).""" + + # EvoService.REFRESH_SYSTEM + with patch("evohomeasync2.location.Location.update") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.REFRESH_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_reset_system( + hass: HomeAssistant, + ctl_id: str, +) -> None: + """Test Evohome's reset_system service (for a temperature control system).""" + + # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_ctl_set_system_mode( + hass: HomeAssistant, + ctl_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_system_mode service (for a temperature control system).""" + + # EvoService.SET_SYSTEM_MODE: Auto + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Auto", + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("Auto", until=None) + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoService.SET_SYSTEM_MODE: AutoWithEco, hours=12 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "AutoWithEco", + ATTR_DURATION: {"hours": 12}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "AutoWithEco", until=datetime(2024, 7, 11, 0, 0, tzinfo=UTC) + ) + + # EvoService.SET_SYSTEM_MODE: Away, days=7 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Away", + ATTR_PERIOD: {"days": 7}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "Away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) + ) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_clear_zone_override( + hass: HomeAssistant, + zone_id: str, +) -> None: + """Test Evohome's clear_zone_override service (for a heating zone).""" + + # EvoZoneMode.FOLLOW_SCHEDULE + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_set_zone_override( + hass: HomeAssistant, + zone_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_zone_override service (for a heating zone).""" + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoZoneMode.PERMANENT_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with(19.5, until=None) + + # EvoZoneMode.TEMPORARY_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + ATTR_DURATION: {"minutes": 135}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + 19.5, until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC) + ) diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index d327bdf14b4..53b9258523d 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -1,4 +1,4 @@ -"""The tests for evohome.""" +"""The tests for Evohome.""" from __future__ import annotations @@ -11,7 +11,7 @@ from evohomeasync2 import EvohomeClient, exceptions as exc import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.evohome.const import DOMAIN, EvoService +from homeassistant.components.evohome.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -187,41 +187,3 @@ async def test_setup( """ assert hass.services.async_services_for_domain(DOMAIN).keys() == snapshot - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_refresh_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.REFRESH_SYSTEM of an evohome system.""" - - # EvoService.REFRESH_SYSTEM - with patch("evohomeasync2.location.Location.update") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.REFRESH_SYSTEM, - {}, - blocking=True, - ) - - mock_fcn.assert_awaited_once_with() - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_reset_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.RESET_SYSTEM of an evohome system.""" - - # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) - with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.RESET_SYSTEM, - {}, - blocking=True, - ) - - mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) From f7a6d163bb132c15d827bd15f33c183afe861a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 12:44:55 +0100 Subject: [PATCH 1149/1435] Add Home Connect functional light color temperature percent setting (#139096) Add functional light color temperature percent setting --- homeassistant/components/home_connect/number.py | 5 +++++ homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 63df33e5432..27b4bc7eb6f 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -83,6 +83,11 @@ NUMBERS = ( device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), + NumberEntityDescription( + key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT, + translation_key="color_temperature_percent", + native_unit_of_measurement="%", + ), NumberEntityDescription( key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL, device_class=NumberDeviceClass.VOLUME, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index dde002d1caa..d6330c8b78b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -874,6 +874,9 @@ "wine_compartment_3_setpoint_temperature": { "name": "Wine compartment 3 temperature" }, + "color_temperature_percent": { + "name": "Functional light color temperature percent" + }, "washer_i_dos_1_base_level": { "name": "i-Dos 1 base level" }, From 4ca39636e27ccfaa271c0bc4784404111874255a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Feb 2025 13:27:14 +0100 Subject: [PATCH 1150/1435] Backup location feature requires Synology DSM 6.0 and higher (#139106) * the filestation api requires dsm 6.0 * fix tests --- .../components/synology_dsm/common.py | 10 +++++++-- tests/components/synology_dsm/common.py | 22 +++++++++++++++++++ tests/components/synology_dsm/conftest.py | 3 +++ tests/components/synology_dsm/test_backup.py | 7 +++--- .../synology_dsm/test_config_flow.py | 11 +++++----- .../synology_dsm/test_media_source.py | 2 ++ tests/components/synology_dsm/test_repairs.py | 5 +++-- 7 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/components/synology_dsm/common.py diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index d61944c146d..2e80624ca5d 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -7,6 +7,7 @@ from collections.abc import Callable from contextlib import suppress import logging +from awesomeversion import AwesomeVersion from synology_dsm import SynologyDSM from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem @@ -135,6 +136,9 @@ class SynoApi: ) await self.async_login() + self.information = self.dsm.information + await self.information.update() + # check if surveillance station is used self._with_surveillance_station = bool( self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY) @@ -165,7 +169,10 @@ class SynoApi: LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) # check if file station is used and permitted - self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY)) + self._with_file_station = bool( + self.information.awesome_version >= AwesomeVersion("6.0") + and self.dsm.apis.get(SynoFileStation.LIST_API_KEY) + ) if self._with_file_station: shares: list | None = None with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): @@ -317,7 +324,6 @@ class SynoApi: async def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" - self.information = self.dsm.information self.network = self.dsm.network await self.network.update() diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py new file mode 100644 index 00000000000..e98b0d21d66 --- /dev/null +++ b/tests/components/synology_dsm/common.py @@ -0,0 +1,22 @@ +"""Configure Synology DSM tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +from awesomeversion import AwesomeVersion + +from .consts import SERIAL + + +def mock_dsm_information( + serial: str | None = SERIAL, + update_result: bool = True, + awesome_version: str = "7.2", +) -> Mock: + """Mock SynologyDSM information.""" + return Mock( + serial=serial, + update=AsyncMock(return_value=update_result), + awesome_version=AwesomeVersion(awesome_version), + ) diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 331c879332d..96d6453cf16 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -8,6 +8,8 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import mock_dsm_information + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -31,6 +33,7 @@ def fixture_dsm(): dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index ea68bbc991c..8e98f4dffa9 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -31,7 +31,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader -from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -99,7 +100,7 @@ def mock_dsm_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ @@ -147,12 +148,12 @@ def mock_dsm_without_filestation(): dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.information = mock_dsm_information() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) dsm.file = None yield dsm diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index b25cf7a81ac..932cf057d3d 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -40,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .common import mock_dsm_information from .consts import ( DEVICE_TOKEN, HOST, @@ -72,7 +73,7 @@ def mock_controller_service(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -95,7 +96,7 @@ def mock_controller_service_2sa(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -116,7 +117,7 @@ def mock_controller_service_vdsm(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -137,7 +138,7 @@ def mock_controller_service_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ @@ -170,7 +171,7 @@ def mock_controller_service_failed(): volumes_ids=[], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=None) + dsm.information = mock_dsm_information(serial=None) dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index baa91822ca0..dd454f92137 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.util.aiohttp import MockRequest +from .common import mock_dsm_information from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import MockConfigEntry @@ -44,6 +45,7 @@ def dsm_with_photos() -> MagicMock: dsm = MagicMock() dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py index b2e7352f214..0dea980b553 100644 --- a/tests/components/synology_dsm/test_repairs.py +++ b/tests/components/synology_dsm/test_repairs.py @@ -25,7 +25,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import ANY, MockConfigEntry from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow @@ -48,7 +49,7 @@ def mock_dsm_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ From 6ebda9322ddb170493d685ff0c374cdfa7c2fd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 13:54:02 +0100 Subject: [PATCH 1151/1435] Fetch allowed values for select entities at Home Connect (#139103) Fetch allowed values for enum settings --- .../components/home_connect/select.py | 30 +++++++--- tests/components/home_connect/test_select.py | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index e4d50b0d5e9..d5657387358 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,6 +1,7 @@ """Provides a select platform for Home Connect.""" from collections.abc import Callable, Coroutine +import contextlib from dataclasses import dataclass from typing import Any, cast @@ -423,13 +424,6 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): appliance, desc, ) - setting = appliance.settings.get(cast(SettingKey, desc.key)) - if setting and setting.constraints and setting.constraints.allowed_values: - self._attr_options = [ - desc.values_translation_key[option] - for option in setting.constraints.allowed_values - if option in desc.values_translation_key - ] async def async_select_option(self, option: str) -> None: """Select new option.""" @@ -459,6 +453,28 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): data.value ) + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key)) + if ( + not setting + or not setting.constraints + or not setting.constraints.allowed_values + ): + with contextlib.suppress(HomeConnectError): + setting = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + ) + + if setting and setting.constraints and setting.constraints.allowed_values: + self._attr_options = [ + self.entity_description.values_translation_key[option] + for option in setting.constraints.allowed_values + if option in self.entity_description.values_translation_key + ] + class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): """Select option class for Home Connect.""" diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index d98dbd8e5f6..22ece365e6b 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -509,6 +509,63 @@ async def test_select_functionality( assert hass.states.is_state(entity_id, value_to_set) +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "test_setting_key", + "allowed_values", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + {str(i) for i in range(1, 50)}, + ), + ], +) +async def test_fetch_allowed_values( + appliance_ha_id: str, + entity_id: str, + test_setting_key: SettingKey, + allowed_values: list[str | None], + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + original_get_setting_side_effect = client.get_setting + + async def get_setting_side_effect( + ha_id: str, setting_key: SettingKey + ) -> GetSetting: + if ha_id != appliance_ha_id or setting_key != test_setting_key: + return await original_get_setting_side_effect(ha_id, setting_key) + return GetSetting( + key=test_setting_key, + raw_key=test_setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + + client.get_setting = AsyncMock(side_effect=get_setting_side_effect) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + @pytest.mark.parametrize( ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), [ From bd919159e58034073eadad8d18fa4faa81df3c6c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 23 Feb 2025 13:59:30 +0100 Subject: [PATCH 1152/1435] Bump aiohue to 4.7.4 (#139108) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 22f1d3991e7..8bc3d84bd50 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.7.3"], + "requirements": ["aiohue==4.7.4"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ef4360a2061..cb03d16903d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,7 +273,7 @@ aiohomekit==3.2.7 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b78b82d8f2e..af58c786530 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -258,7 +258,7 @@ aiohomekit==3.2.7 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 From 15ca2fe4890fe801b9e51ea7fe9e7420f61e0314 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Sun, 23 Feb 2025 13:21:41 +0000 Subject: [PATCH 1153/1435] Waze action support entities (#139068) --- .../components/waze_travel_time/__init__.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 34f22c9218f..3a91690ef07 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -17,6 +17,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.selector import ( BooleanSelector, SelectSelector, @@ -115,10 +116,21 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b client = WazeRouteCalculator( region=service.data[CONF_REGION].upper(), client=httpx_client ) + + origin_coordinates = find_coordinates(hass, service.data[CONF_ORIGIN]) + destination_coordinates = find_coordinates(hass, service.data[CONF_DESTINATION]) + + origin = origin_coordinates if origin_coordinates else service.data[CONF_ORIGIN] + destination = ( + destination_coordinates + if destination_coordinates + else service.data[CONF_DESTINATION] + ) + response = await async_get_travel_times( client=client, - origin=service.data[CONF_ORIGIN], - destination=service.data[CONF_DESTINATION], + origin=origin, + destination=destination, vehicle_type=service.data[CONF_VEHICLE_TYPE], avoid_toll_roads=service.data[CONF_AVOID_TOLL_ROADS], avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS], From 800fe1b01e2d89d37eff2ce3cdc0c2c1885f7916 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sun, 23 Feb 2025 14:42:54 +0100 Subject: [PATCH 1154/1435] Remove individual lcn devices for each entity (#136450) --- homeassistant/components/lcn/__init__.py | 4 ++ homeassistant/components/lcn/entity.py | 35 +++++----------- homeassistant/components/lcn/helpers.py | 44 --------------------- tests/components/lcn/test_device_trigger.py | 16 ++++---- 4 files changed, 23 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 58924413c56..256e132b30d 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -49,6 +49,7 @@ from .helpers import ( InputType, async_update_config_entry, generate_unique_id, + purge_device_registry, register_lcn_address_devices, register_lcn_host_device, ) @@ -120,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b register_lcn_host_device(hass, config_entry) register_lcn_address_devices(hass, config_entry) + # clean up orphaned devices + purge_device_registry(hass, config_entry.entry_id, {**config_entry.data}) + # forward config_entry to components await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index 12d8f966801..ffb680c4237 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -3,19 +3,18 @@ from collections.abc import Callable from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from .const import CONF_DOMAIN_DATA, DOMAIN +from .const import DOMAIN from .helpers import ( AddressType, DeviceConnectionType, InputType, generate_unique_id, get_device_connection, - get_device_model, ) @@ -36,6 +35,14 @@ class LcnEntity(Entity): self.address: AddressType = config[CONF_ADDRESS] self._unregister_for_inputs: Callable | None = None self._name: str = config[CONF_NAME] + self._attr_device_info = DeviceInfo( + identifiers={ + ( + DOMAIN, + generate_unique_id(self.config_entry.entry_id, self.address), + ) + }, + ) @property def unique_id(self) -> str: @@ -44,28 +51,6 @@ class LcnEntity(Entity): self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] ) - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}" - model = ( - "LCN resource" - f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})" - ) - - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=f"{address}.{self.config[CONF_RESOURCE]}", - model=model, - manufacturer="Issendorff", - via_device=( - DOMAIN, - generate_unique_id( - self.config_entry.entry_id, self.config[CONF_ADDRESS] - ), - ), - ) - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self.device_connection = get_device_connection( diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index b999c6f3770..2176c669251 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from copy import deepcopy -from itertools import chain import re from typing import cast @@ -22,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_RESOURCE, CONF_SENSORS, - CONF_SOURCE, CONF_SWITCHES, ) from homeassistant.core import HomeAssistant @@ -30,23 +28,14 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import ( - BINSENSOR_PORTS, CONF_CLIMATES, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, - CONF_OUTPUT, CONF_SCENES, CONF_SOFTWARE_SERIAL, CONNECTION, DEVICE_CONNECTIONS, DOMAIN, - LED_PORTS, - LOGICOP_PORTS, - OUTPUT_PORTS, - S0_INPUTS, - SETPOINTS, - THRESHOLDS, - VARIABLES, ) # typing @@ -96,31 +85,6 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: raise ValueError("Unknown domain") -def get_device_model(domain_name: str, domain_data: ConfigType) -> str: - """Return the model for the specified domain_data.""" - if domain_name in ("switch", "light"): - return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay" - if domain_name in ("binary_sensor", "sensor"): - if domain_data[CONF_SOURCE] in BINSENSOR_PORTS: - return "Binary Sensor" - if domain_data[CONF_SOURCE] in chain( - VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS - ): - return "Variable" - if domain_data[CONF_SOURCE] in LED_PORTS: - return "Led" - if domain_data[CONF_SOURCE] in LOGICOP_PORTS: - return "Logical Operation" - return "Key" - if domain_name == "cover": - return "Motor" - if domain_name == "climate": - return "Regulator" - if domain_name == "scene": - return "Scene" - raise ValueError("Unknown domain") - - def generate_unique_id( entry_id: str, address: AddressType, @@ -169,13 +133,6 @@ def purge_device_registry( ) -> None: """Remove orphans from device registry which are not in entry data.""" device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - - # Find all devices that are referenced in the entity registry. - references_entities = { - entry.device_id - for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id) - } # Find device that references the host. references_host = set() @@ -198,7 +155,6 @@ def purge_device_registry( entry.id for entry in dr.async_entries_for_config_entry(device_registry, entry_id) } - - references_entities - references_host - references_entry_data ) diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 6537c108981..94eb96591e2 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -45,9 +45,14 @@ async def test_get_triggers_module_device( ) ] - triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, device.id - ) + triggers = [ + trigger + for trigger in await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + if trigger[CONF_DOMAIN] == DOMAIN + ] + assert triggers == unordered(expected_triggers) @@ -63,11 +68,8 @@ async def test_get_triggers_non_module_device( identifiers={(DOMAIN, entry.entry_id)} ) group_device = get_device(hass, entry, (0, 5, True)) - resource_device = device_registry.async_get_device( - identifiers={(DOMAIN, f"{entry.entry_id}-m000007-output1")} - ) - for device in (host_device, group_device, resource_device): + for device in (host_device, group_device): triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) From c1e5673cbd11b84d7146eaa4fddd07308ebcc447 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 14:46:37 +0100 Subject: [PATCH 1155/1435] Allow rename of the backup folder for OneDrive (#138407) --- homeassistant/components/onedrive/__init__.py | 104 ++++++--- homeassistant/components/onedrive/backup.py | 2 +- .../components/onedrive/config_flow.py | 158 +++++++++++-- homeassistant/components/onedrive/const.py | 2 + .../components/onedrive/quality_scale.yaml | 5 +- .../components/onedrive/strings.json | 28 ++- tests/components/onedrive/conftest.py | 113 +++++++++- tests/components/onedrive/const.py | 45 +--- tests/components/onedrive/test_config_flow.py | 212 +++++++++++++++++- tests/components/onedrive/test_init.py | 128 ++++++++++- 10 files changed, 681 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 4aa11daf39d..6805b073ea2 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from html import unescape from json import dumps, loads import logging @@ -10,10 +11,10 @@ from typing import cast from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk.exceptions import ( AuthenticationError, - HttpRequestException, + NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import ItemUpdate +from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback @@ -25,7 +26,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .coordinator import ( OneDriveConfigEntry, OneDriveRuntimeData, @@ -50,33 +51,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> client = OneDriveClient(get_access_token, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist - try: - 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, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": "approot"}, - ) from err + approot = await _handle_item_operation(client.get_approot, "approot") + folder_name = entry.data[CONF_FOLDER_NAME] - 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 + backup_folder = await _handle_item_operation( + lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]), + folder_name, + ) + except NotFoundError: + _LOGGER.debug("Creating backup folder %s", folder_name) + backup_folder = await _handle_item_operation( + lambda: client.create_folder(parent_id=approot.id, name=folder_name), + folder_name, + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id} + ) + + # write instance id to description + if backup_folder.description != (instance_id := await async_get_instance_id(hass)): + await _handle_item_operation( + lambda: client.update_drive_item( + backup_folder.id, ItemUpdate(description=instance_id) + ), + folder_name, + ) + + # update in case folder was renamed manually inside OneDrive + if backup_folder.name != entry.data[CONF_FOLDER_NAME]: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_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": backup_folder_name}, - ) from err coordinator = OneDriveUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() @@ -152,3 +158,47 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) - data=ItemUpdate(description=""), ) _LOGGER.debug("Migrated backup file %s", file.name) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1: + _LOGGER.debug( + "Migrating OneDrive config entry from version %s.%s", version, minor_version + ) + + instance_id = await async_get_instance_id(hass) + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", + }, + ) + _LOGGER.debug("Migration to version 1.2 successful") + return True + + +async def _handle_item_operation( + func: Callable[[], Awaitable[Item]], folder: str +) -> Item: + try: + return await func() + except NotFoundError: + raise + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (OneDriveException, TimeoutError) as err: + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index f8a2a6699c4..9c7371bee4b 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -74,7 +74,7 @@ def async_register_backup_agents_listener( def handle_backup_errors[_R, **P]( func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: - """Handle backup errors with a specific translation key.""" + """Handle backup errors.""" @wraps(func) async def wrapper( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 06c9ec253e3..3374c0369ee 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -8,22 +8,47 @@ from typing import Any, cast from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES +from .const import ( + CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) from .coordinator import OneDriveConfigEntry +FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str}) + class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle OneDrive OAuth2 authentication.""" DOMAIN = DOMAIN + MINOR_VERSION = 2 + + client: OneDriveClient + approot: AppRoot + + def __init__(self) -> None: + """Initialize the OneDrive config flow.""" + super().__init__() + self.step_data: dict[str, Any] = {} @property def logger(self) -> logging.Logger: @@ -35,6 +60,15 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Extra data that needs to be appended to the authorize url.""" return {"scope": " ".join(OAUTH_SCOPES)} + @property + def apps_folder(self) -> str: + """Return the name of the Apps folder (translated).""" + return ( + path.split("/")[-1] + if (path := self.approot.parent_reference.path) + else "Apps" + ) + async def async_oauth_create_entry( self, data: dict[str, Any], @@ -44,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def get_access_token() -> str: return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - graph_client = OneDriveClient( + self.client = OneDriveClient( get_access_token, async_get_clientsession(self.hass) ) try: - approot = await graph_client.get_approot() + self.approot = await self.client.get_approot() except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") @@ -57,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - await self.async_set_unique_id(approot.parent_reference.drive_id) + await self.async_set_unique_id(self.approot.parent_reference.drive_id) - if self.source == SOURCE_REAUTH: - reauth_entry = self._get_reauth_entry() + if self.source != SOURCE_USER: self._abort_if_unique_id_mismatch( reason="wrong_drive", ) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( entry=reauth_entry, data=data, ) - self._abort_if_unique_id_configured() + if self.source != SOURCE_RECONFIGURE: + self._abort_if_unique_id_configured() - title = ( - f"{approot.created_by.user.display_name}'s OneDrive" - if approot.created_by.user and approot.created_by.user.display_name - else "OneDrive" + self.step_data = data + + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_reconfigure_folder() + + return await self.async_step_folder_name() + + async def async_step_folder_name( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask for the folder name.""" + errors: dict[str, str] = {} + instance_id = await async_get_instance_id(self.hass) + if user_input is not None: + try: + folder = await self.client.create_folder( + self.approot.id, user_input[CONF_FOLDER_NAME] + ) + except OneDriveException: + self.logger.debug("Failed to create folder", exc_info=True) + errors["base"] = "folder_creation_error" + else: + if folder.description and folder.description != instance_id: + errors[CONF_FOLDER_NAME] = "folder_already_in_use" + if not errors: + title = ( + f"{self.approot.created_by.user.display_name}'s OneDrive" + if self.approot.created_by.user + and self.approot.created_by.user.display_name + else "OneDrive" + ) + return self.async_create_entry( + title=title, + data={ + **self.step_data, + CONF_FOLDER_ID: folder.id, + CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME], + }, + ) + + default_folder_name = ( + f"backups_{instance_id[:8]}" + if user_input is None + else user_input[CONF_FOLDER_NAME] + ) + + return self.async_show_form( + step_id="folder_name", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name} + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, + ) + + async def async_step_reconfigure_folder( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the folder name.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + if ( + new_folder_name := user_input[CONF_FOLDER_NAME] + ) != reconfigure_entry.data[CONF_FOLDER_NAME]: + try: + await self.client.update_drive_item( + reconfigure_entry.data[CONF_FOLDER_ID], + ItemUpdate(name=new_folder_name), + ) + except OneDriveException: + self.logger.debug("Failed to update folder", exc_info=True) + errors["base"] = "folder_rename_error" + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name}, + ) + + return self.async_show_form( + step_id="reconfigure_folder", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, + {CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]}, + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, ) - return self.async_create_entry(title=title, data=data) async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -92,6 +218,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py index 7aefa26ea81..fd21d84369c 100644 --- a/homeassistant/components/onedrive/const.py +++ b/homeassistant/components/onedrive/const.py @@ -6,6 +6,8 @@ from typing import Final from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "onedrive" +CONF_FOLDER_NAME: Final = "folder_name" +CONF_FOLDER_ID: Final = "folder_id" CONF_DELETE_PERMANENTLY: Final = "delete_permanently" diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index 44754e76f2c..dd9e7f26102 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -73,10 +73,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: exempt - comment: | - Nothing to reconfigure. + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 27afe3e8a9b..37e19eb68ca 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -7,6 +7,26 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The OneDrive integration needs to re-authenticate your account" + }, + "folder_name": { + "title": "Pick a folder name", + "description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`", + "data": { + "folder_name": "Folder name" + }, + "data_description": { + "folder_name": "Name of the folder" + } + }, + "reconfigure_folder": { + "title": "Change the folder name", + "description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.", + "data": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]" + }, + "data_description": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]" + } } }, "abort": { @@ -23,10 +43,16 @@ "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%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "error": { + "folder_rename_error": "Failed to rename folder", + "folder_creation_error": "Failed to create folder", + "folder_already_in_use": "Folder already used for backups from another Home Assistant instance" } }, "options": { diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index ed419c820a9..8ff650012f9 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -5,13 +5,28 @@ from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch +from onedrive_personal_sdk.const import DriveState, DriveType +from onedrive_personal_sdk.models.items import ( + AppRoot, + Drive, + DriveQuota, + Folder, + IdentitySet, + ItemParentReference, + User, +) import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -19,10 +34,9 @@ from .const import ( BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, - MOCK_APPROOT, + IDENTITY_SET, + INSTANCE_ID, MOCK_BACKUP_FILE, - MOCK_BACKUP_FOLDER, - MOCK_DRIVE, MOCK_METADATA_FILE, ) @@ -66,8 +80,11 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "expires_at": expires_at, "scope": " ".join(scopes), }, + CONF_FOLDER_NAME: "backups_123", + CONF_FOLDER_ID: "my_folder_id", }, unique_id="mock_drive_id", + minor_version=2, ) @@ -87,14 +104,80 @@ def mock_onedrive_client_init() -> Generator[MagicMock]: yield onedrive_client +@pytest.fixture +def mock_approot() -> AppRoot: + """Return a mocked approot.""" + return AppRoot( + id="id", + child_count=0, + size=0, + name="name", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) + ), + ) + + +@pytest.fixture +def mock_drive() -> Drive: + """Return a mocked drive.""" + return Drive( + id="mock_drive_id", + name="My Drive", + drive_type=DriveType.PERSONAL, + owner=IDENTITY_SET, + quota=DriveQuota( + deleted=5, + remaining=805306368, + state=DriveState.NEARING, + total=5368709120, + used=4250000000, + ), + ) + + +@pytest.fixture +def mock_folder() -> Folder: + """Return a mocked backup folder.""" + return Folder( + id="my_folder_id", + name="name", + size=0, + child_count=0, + description="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ), + ), + ) + + @pytest.fixture(autouse=True) -def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]: +def mock_onedrive_client( + mock_onedrive_client_init: MagicMock, + mock_approot: AppRoot, + mock_drive: Drive, + mock_folder: Folder, +) -> 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.get_approot.return_value = mock_approot + client.create_folder.return_value = mock_folder client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] - client.get_drive_item.return_value = MOCK_BACKUP_FILE + client.get_drive_item.return_value = mock_folder client.upload_file.return_value = MOCK_METADATA_FILE class MockStreamReader: @@ -105,7 +188,7 @@ def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[Magi return dumps(BACKUP_METADATA).encode() client.download_drive_item.return_value = MockStreamReader() - client.get_drive.return_value = MOCK_DRIVE + client.get_drive.return_value = mock_drive return client @@ -131,8 +214,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(autouse=True) def mock_instance_id() -> Generator[AsyncMock]: """Mock the instance ID.""" - with patch( - "homeassistant.components.onedrive.async_get_instance_id", - return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + with ( + patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value=INSTANCE_ID, + ) as mock_instance_id, + patch( + "homeassistant.components.onedrive.config_flow.async_get_instance_id", + new=mock_instance_id, + ), ): yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 0c04a6f4c82..6e91a7ef0ea 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -3,13 +3,8 @@ from html import escape from json import dumps -from onedrive_personal_sdk.const import DriveState, DriveType from onedrive_personal_sdk.models.items import ( - AppRoot, - Drive, - DriveQuota, File, - Folder, Hashes, IdentitySet, ItemParentReference, @@ -34,6 +29,8 @@ BACKUP_METADATA = { "size": 34519040, } +INSTANCE_ID = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0" + IDENTITY_SET = IdentitySet( user=User( display_name="John Doe", @@ -42,28 +39,6 @@ IDENTITY_SET = IdentitySet( ) ) -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=IDENTITY_SET, -) - -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=IDENTITY_SET, -) - MOCK_BACKUP_FILE = File( id="id", name="23e64aec.tar", @@ -75,7 +50,6 @@ MOCK_BACKUP_FILE = File( quick_xor_hash="hash", ), mime_type="application/x-tar", - description="", created_by=IDENTITY_SET, ) @@ -101,18 +75,3 @@ MOCK_METADATA_FILE = File( ), created_by=IDENTITY_SET, ) - - -MOCK_DRIVE = Drive( - id="mock_drive_id", - name="My Drive", - drive_type=DriveType.PERSONAL, - owner=IDENTITY_SET, - quota=DriveQuota( - deleted=5, - remaining=805306368, - state=DriveState.NEARING, - total=5368709120, - used=4250000000, - ), -) diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 1ae92332075..81cd44bd041 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -4,11 +4,14 @@ from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, Folder, ItemUpdate import pytest from homeassistant import config_entries from homeassistant.components.onedrive.const import ( CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, @@ -20,7 +23,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, MOCK_APPROOT +from .const import CLIENT_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -85,6 +88,11 @@ async def test_full_flow( token_callback = mock_onedrive_client_init.call_args[0][0] assert await token_callback() == "mock-access-token" + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -92,6 +100,8 @@ async def test_full_flow( 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" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" @pytest.mark.usefixtures("current_request_with_host") @@ -101,10 +111,11 @@ async def test_full_flow_with_owner_not_found( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_onedrive_client: MagicMock, + mock_approot: 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 + mock_approot.created_by.user = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -112,6 +123,11 @@ async def test_full_flow_with_owner_not_found( 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.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -119,6 +135,94 @@ async def test_full_flow_with_owner_not_found( 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" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + mock_onedrive_client.reset_mock() + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_folder_already_in_use( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, + mock_instance_id: AsyncMock, + mock_folder: Folder, +) -> None: + """Ensure a folder that is already in use is not allowed.""" + + mock_folder.description = "1234" + + 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.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_FOLDER_NAME: "folder_already_in_use"} + + # clear error and try again + mock_onedrive_client.create_folder.return_value.description = mock_instance_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's 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" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_error_during_folder_creation( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, +) -> None: + """Ensure we can create the backup folder.""" + + mock_onedrive_client.create_folder.side_effect = OneDriveException() + + 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.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "folder_creation_error"} + + mock_onedrive_client.create_folder.side_effect = None + + # clear error and try again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's 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" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" @pytest.mark.usefixtures("current_request_with_host") @@ -205,11 +309,11 @@ async def test_reauth_flow_id_changed( mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_approot: AppRoot, ) -> None: """Test that the reauth flow fails on a different drive id.""" - app_root = MOCK_APPROOT - app_root.parent_reference.drive_id = "other_drive_id" - mock_onedrive_client.get_approot.return_value = app_root + + mock_approot.parent_reference.drive_id = "other_drive_id" await setup_integration(hass, mock_config_entry) @@ -226,6 +330,104 @@ async def test_reauth_flow_id_changed( assert result["reason"] == "wrong_drive" +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow.""" + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + 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.FORM + assert result["step_id"] == "reconfigure_folder" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.ABORT + mock_onedrive_client.update_drive_item.assert_called_once_with( + mock_config_entry.data[CONF_FOLDER_ID], ItemUpdate(name="newFolder") + ) + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow errors.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + 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.FORM + assert result["step_id"] == "reconfigure_folder" + + mock_onedrive_client.update_drive_item.side_effect = OneDriveException() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + assert result["errors"] == {"base": "folder_rename_error"} + + # clear side effect + mock_onedrive_client.update_drive_item.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, +) -> None: + """Test that the reconfigure flow fails on a different drive id.""" + + mock_approot.parent_reference.drive_id = "other_drive_id" + + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + 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.ABORT + assert result["reason"] == "wrong_drive" + + async def test_options_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index b4ec138ebf4..41c1966a4ae 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,22 +1,31 @@ """Test the OneDrive setup.""" -from copy import deepcopy +from copy import copy from html import escape from json import dumps from unittest.mock import MagicMock from onedrive_personal_sdk.const import DriveState -from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + NotFoundError, + OneDriveException, +) +from onedrive_personal_sdk.models.items import AppRoot, Drive, Folder, ItemUpdate import pytest from syrupy import SnapshotAssertion -from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import setup_integration -from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_DRIVE +from .const import BACKUP_METADATA, INSTANCE_ID, MOCK_BACKUP_FILE from tests.common import MockConfigEntry @@ -72,11 +81,64 @@ async def test_get_integration_folder_error( mock_onedrive_client: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: - """Test faulty approot retrieval.""" - mock_onedrive_client.create_folder.side_effect = OneDriveException() + """Test faulty integration folder retrieval.""" + mock_onedrive_client.get_drive_item.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 + assert "Failed to get backups_123 folder" in caplog.text + + +async def test_get_integration_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, + mock_folder: Folder, +) -> None: + """Test faulty integration folder creation.""" + folder_name = copy(mock_config_entry.data[CONF_FOLDER_NAME]) + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_onedrive_client.create_folder.assert_called_once_with( + parent_id=mock_approot.id, + name=folder_name, + ) + # ensure the folder id and name are updated + assert mock_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert mock_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + + +async def test_get_integration_folder_creation_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty integration folder creation error.""" + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + 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_123 folder" in caplog.text + + +async def test_update_instance_id_description( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_folder: Folder, +) -> None: + """Test we write the instance id to the folder.""" + mock_folder.description = "" + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + mock_onedrive_client.update_drive_item.assert_called_with( + mock_folder.id, ItemUpdate(description=INSTANCE_ID) + ) async def test_migrate_metadata_files( @@ -125,12 +187,13 @@ async def test_device( mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, + mock_drive: Drive, ) -> None: """Test the device.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) + device = device_registry.async_get_device({(DOMAIN, mock_drive.id)}) assert device assert device == snapshot @@ -154,17 +217,62 @@ async def test_data_cap_issues( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_drive: Drive, drive_state: DriveState, issue_key: str, issue_exists: bool, ) -> None: """Make sure we get issues for high data usage.""" - mock_drive = deepcopy(MOCK_DRIVE) assert mock_drive.quota mock_drive.quota.state = drive_state - mock_onedrive_client.get_drive.return_value = mock_drive + await setup_integration(hass, mock_config_entry) issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue(DOMAIN, issue_key) assert (issue is not None) == issue_exists + + +async def test_1_1_to_1_2_migration( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_folder: Folder, +) -> None: + """Test migration from 1.1 to 1.2.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + + # will always 404 after migration, because of dummy id + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + + +async def test_migration_guard_against_major_downgrade( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration guards against major downgrades.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + version=2, + ) + + await setup_integration(hass, old_config_entry) + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR From 1cd82ab8eea77d09e1261401fa7ec23362f59330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 16:18:20 +0100 Subject: [PATCH 1156/1435] Deprecate Home Connect command actions (#139093) * Deprecate command actions * Improve issue description * Improve issue description Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 12 +++++++++++ .../components/home_connect/strings.json | 4 ++++ tests/components/home_connect/test_init.py | 21 ++++++++++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 637fd7aa3a8..51b38bf7cd3 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -405,6 +405,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: """Execute calls to services executing a command.""" client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + async_create_issue( + hass, + DOMAIN, + "deprecated_command_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_command_actions", + ) + try: await client.put_command(ha_id, command_key=command_key, value=True) except HomeConnectError as err: @@ -610,6 +621,7 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") + async_delete_issue(hass, DOMAIN, "deprecated_command_actions") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d6330c8b78b..977ad1f36f0 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -108,6 +108,10 @@ "title": "Deprecated binary door sensor detected in some automations or scripts", "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." }, + "deprecated_command_actions": { + "title": "The command related actions are deprecated in favor of the new buttons", + "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + }, "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 5e309a7446e..06498f891db 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -338,11 +338,27 @@ async def test_key_value_services( @pytest.mark.parametrize( - "service_call", - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], ) async def test_programs_and_options_actions_deprecation( service_call: dict[str, Any], + issue_id: str, hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, @@ -354,7 +370,6 @@ async def test_programs_and_options_actions_deprecation( hass_client: ClientSessionGenerator, ) -> None: """Test deprecated service keys.""" - issue_id = "deprecated_set_program_and_option_actions" assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED From 0b961d98f58fbb61791f80fcc35a2dd80c621e66 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 16:32:55 +0100 Subject: [PATCH 1157/1435] Move remember the milk config storage to own module (#138999) --- .../components/remember_the_milk/__init__.py | 130 ++---------------- .../components/remember_the_milk/const.py | 5 + .../components/remember_the_milk/entity.py | 22 ++- .../components/remember_the_milk/storage.py | 115 ++++++++++++++++ .../{test_init.py => test_storage.py} | 14 +- 5 files changed, 148 insertions(+), 138 deletions(-) create mode 100644 homeassistant/components/remember_the_milk/const.py create mode 100644 homeassistant/components/remember_the_milk/storage.py rename tests/components/remember_the_milk/{test_init.py => test_storage.py} (90%) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 2a95ed46b20..fc192bd538a 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -1,33 +1,25 @@ """Support to interact with Remember The Milk.""" -import json -import logging -from pathlib import Path - from rtmapi import Rtm import voluptuous as vol from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from .const import LOGGER from .entity import RememberTheMilkEntity +from .storage import RememberTheMilkConfiguration # httplib2 is a transitive dependency from RtmAPI. If this dependency is not # set explicitly, the library does not work. -_LOGGER = logging.getLogger(__name__) DOMAIN = "remember_the_milk" -DEFAULT_NAME = DOMAIN CONF_SHARED_SECRET = "shared_secret" -CONF_ID_MAP = "id_map" -CONF_LIST_ID = "list_id" -CONF_TIMESERIES_ID = "timeseries_id" -CONF_TASK_ID = "task_id" RTM_SCHEMA = vol.Schema( { @@ -41,7 +33,6 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA ) -CONFIG_FILE_NAME = ".remember_the_milk.conf" SERVICE_CREATE_TASK = "create_task" SERVICE_COMPLETE_TASK = "complete_task" @@ -54,17 +45,17 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass) + component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) stored_rtm_config = RememberTheMilkConfiguration(hass) for rtm_config in config[DOMAIN]: account_name = rtm_config[CONF_NAME] - _LOGGER.debug("Adding Remember the milk account %s", account_name) + LOGGER.debug("Adding Remember the milk account %s", account_name) api_key = rtm_config[CONF_API_KEY] shared_secret = rtm_config[CONF_SHARED_SECRET] token = stored_rtm_config.get_token(account_name) if token: - _LOGGER.debug("found token for account %s", account_name) + LOGGER.debug("found token for account %s", account_name) _create_instance( hass, account_name, @@ -79,7 +70,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, account_name, api_key, shared_secret, stored_rtm_config, component ) - _LOGGER.debug("Finished adding all Remember the milk accounts") + LOGGER.debug("Finished adding all Remember the milk accounts") return True @@ -110,21 +101,21 @@ def _register_new_account( request_id = None api = Rtm(api_key, shared_secret, "write", None) url, frob = api.authenticate_desktop() - _LOGGER.debug("Sent authentication request to server") + LOGGER.debug("Sent authentication request to server") def register_account_callback(fields: list[dict[str, str]]) -> None: """Call for register the configurator.""" api.retrieve_token(frob) token = api.token if api.token is None: - _LOGGER.error("Failed to register, please try again") + LOGGER.error("Failed to register, please try again") configurator.notify_errors( hass, request_id, "Failed to register, please try again." ) return stored_rtm_config.set_token(account_name, token) - _LOGGER.debug("Retrieved new token from server") + LOGGER.debug("Retrieved new token from server") _create_instance( hass, @@ -152,104 +143,3 @@ def _register_new_account( link_url=url, submit_caption="login completed", ) - - -class RememberTheMilkConfiguration: - """Internal configuration data for RememberTheMilk class. - - This class stores the authentication token it get from the backend. - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Create new instance of configuration.""" - self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - self._config = {} - _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) - try: - self._config = json.loads( - Path(self._config_file_path).read_text(encoding="utf8") - ) - except FileNotFoundError: - _LOGGER.debug("Missing configuration file: %s", self._config_file_path) - except OSError: - _LOGGER.debug( - "Failed to read from configuration file, %s, using empty configuration", - self._config_file_path, - ) - except ValueError: - _LOGGER.error( - "Failed to parse configuration file, %s, using empty configuration", - self._config_file_path, - ) - - def _save_config(self) -> None: - """Write the configuration to a file.""" - Path(self._config_file_path).write_text( - json.dumps(self._config), encoding="utf8" - ) - - def get_token(self, profile_name: str) -> str | None: - """Get the server token for a profile.""" - if profile_name in self._config: - return self._config[profile_name][CONF_TOKEN] - return None - - def set_token(self, profile_name: str, token: str) -> None: - """Store a new server token for a profile.""" - self._initialize_profile(profile_name) - self._config[profile_name][CONF_TOKEN] = token - self._save_config() - - def delete_token(self, profile_name: str) -> None: - """Delete a token for a profile. - - Usually called when the token has expired. - """ - self._config.pop(profile_name, None) - self._save_config() - - def _initialize_profile(self, profile_name: str) -> None: - """Initialize the data structures for a profile.""" - if profile_name not in self._config: - self._config[profile_name] = {} - if CONF_ID_MAP not in self._config[profile_name]: - self._config[profile_name][CONF_ID_MAP] = {} - - def get_rtm_id( - self, profile_name: str, hass_id: str - ) -> tuple[str, str, str] | None: - """Get the RTM ids for a Home Assistant task ID. - - The id of a RTM tasks consists of the tuple: - list id, timeseries id and the task id. - """ - self._initialize_profile(profile_name) - ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) - if ids is None: - return None - return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] - - def set_rtm_id( - self, - profile_name: str, - hass_id: str, - list_id: str, - time_series_id: str, - rtm_task_id: str, - ) -> None: - """Add/Update the RTM task ID for a Home Assistant task IS.""" - self._initialize_profile(profile_name) - id_tuple = { - CONF_LIST_ID: list_id, - CONF_TIMESERIES_ID: time_series_id, - CONF_TASK_ID: rtm_task_id, - } - self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple - self._save_config() - - def delete_rtm_id(self, profile_name: str, hass_id: str) -> None: - """Delete a key mapping.""" - self._initialize_profile(profile_name) - if hass_id in self._config[profile_name][CONF_ID_MAP]: - del self._config[profile_name][CONF_ID_MAP][hass_id] - self._save_config() diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py new file mode 100644 index 00000000000..2fccbf3ee52 --- /dev/null +++ b/homeassistant/components/remember_the_milk/const.py @@ -0,0 +1,5 @@ +"""Constants for the Remember The Milk integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 5f618a96c11..bf75debe367 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -1,14 +1,12 @@ """Support to interact with Remember The Milk.""" -import logging - from rtmapi import Rtm, RtmRequestFailedException from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK from homeassistant.core import ServiceCall from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +from .const import LOGGER class RememberTheMilkEntity(Entity): @@ -24,7 +22,7 @@ class RememberTheMilkEntity(Entity): self._rtm_api = Rtm(api_key, shared_secret, "delete", token) self._token_valid = None self._check_token() - _LOGGER.debug("Instance created for account %s", self._name) + LOGGER.debug("Instance created for account %s", self._name) def _check_token(self): """Check if the API token is still valid. @@ -34,7 +32,7 @@ class RememberTheMilkEntity(Entity): """ valid = self._rtm_api.token_valid() if not valid: - _LOGGER.error( + LOGGER.error( "Token for account %s is invalid. You need to register again!", self.name, ) @@ -64,7 +62,7 @@ class RememberTheMilkEntity(Entity): result = self._rtm_api.rtm.tasks.add( timeline=timeline, name=task_name, parse="1" ) - _LOGGER.debug( + LOGGER.debug( "Created new task '%s' in account %s", task_name, self.name ) if hass_id is not None: @@ -83,14 +81,14 @@ class RememberTheMilkEntity(Entity): task_id=rtm_id[2], timeline=timeline, ) - _LOGGER.debug( + LOGGER.debug( "Updated task with id '%s' in account %s to name %s", hass_id, self.name, task_name, ) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, @@ -101,7 +99,7 @@ class RememberTheMilkEntity(Entity): hass_id = call.data[CONF_ID] rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) if rtm_id is None: - _LOGGER.error( + LOGGER.error( ( "Could not find task with ID %s in account %s. " "So task could not be closed" @@ -120,11 +118,9 @@ class RememberTheMilkEntity(Entity): timeline=timeline, ) self._rtm_config.delete_rtm_id(self._name, hass_id) - _LOGGER.debug( - "Completed task with id %s in account %s", hass_id, self._name - ) + LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py new file mode 100644 index 00000000000..ae51acd963b --- /dev/null +++ b/homeassistant/components/remember_the_milk/storage.py @@ -0,0 +1,115 @@ +"""Store RTM configuration in Home Assistant storage.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .const import LOGGER + +CONFIG_FILE_NAME = ".remember_the_milk.conf" +CONF_ID_MAP = "id_map" +CONF_LIST_ID = "list_id" +CONF_TASK_ID = "task_id" +CONF_TIMESERIES_ID = "timeseries_id" + + +class RememberTheMilkConfiguration: + """Internal configuration data for Remember The Milk.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Create new instance of configuration.""" + self._config_file_path = hass.config.path(CONFIG_FILE_NAME) + self._config = {} + LOGGER.debug("Loading configuration from file: %s", self._config_file_path) + try: + self._config = json.loads( + Path(self._config_file_path).read_text(encoding="utf8") + ) + except FileNotFoundError: + LOGGER.debug("Missing configuration file: %s", self._config_file_path) + except OSError: + LOGGER.debug( + "Failed to read from configuration file, %s, using empty configuration", + self._config_file_path, + ) + except ValueError: + LOGGER.error( + "Failed to parse configuration file, %s, using empty configuration", + self._config_file_path, + ) + + def _save_config(self) -> None: + """Write the configuration to a file.""" + Path(self._config_file_path).write_text( + json.dumps(self._config), encoding="utf8" + ) + + def get_token(self, profile_name: str) -> str | None: + """Get the server token for a profile.""" + if profile_name in self._config: + return self._config[profile_name][CONF_TOKEN] + return None + + def set_token(self, profile_name: str, token: str) -> None: + """Store a new server token for a profile.""" + self._initialize_profile(profile_name) + self._config[profile_name][CONF_TOKEN] = token + self._save_config() + + def delete_token(self, profile_name: str) -> None: + """Delete a token for a profile. + + Usually called when the token has expired. + """ + self._config.pop(profile_name, None) + self._save_config() + + def _initialize_profile(self, profile_name: str) -> None: + """Initialize the data structures for a profile.""" + if profile_name not in self._config: + self._config[profile_name] = {} + if CONF_ID_MAP not in self._config[profile_name]: + self._config[profile_name][CONF_ID_MAP] = {} + + def get_rtm_id( + self, profile_name: str, hass_id: str + ) -> tuple[str, str, str] | None: + """Get the RTM ids for a Home Assistant task ID. + + The id of a RTM tasks consists of the tuple: + list id, timeseries id and the task id. + """ + self._initialize_profile(profile_name) + ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) + if ids is None: + return None + return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] + + def set_rtm_id( + self, + profile_name: str, + hass_id: str, + list_id: str, + time_series_id: str, + rtm_task_id: str, + ) -> None: + """Add/Update the RTM task ID for a Home Assistant task IS.""" + self._initialize_profile(profile_name) + id_tuple = { + CONF_LIST_ID: list_id, + CONF_TIMESERIES_ID: time_series_id, + CONF_TASK_ID: rtm_task_id, + } + self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple + self._save_config() + + def delete_rtm_id(self, profile_name: str, hass_id: str) -> None: + """Delete a key mapping.""" + self._initialize_profile(profile_name) + if hass_id in self._config[profile_name][CONF_ID_MAP]: + del self._config[profile_name][CONF_ID_MAP][hass_id] + self._save_config() diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_storage.py similarity index 90% rename from tests/components/remember_the_milk/test_init.py rename to tests/components/remember_the_milk/test_storage.py index 517c8cebc0e..6ae774a3d0d 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_storage.py @@ -14,7 +14,9 @@ from .const import JSON_STRING, PROFILE, TOKEN def test_set_get_delete_token(hass: HomeAssistant) -> None: """Test set, get and delete token.""" open_mock = mock_open() - with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", open_mock + ): config = rtm.RememberTheMilkConfiguration(hass) assert open_mock.return_value.write.call_count == 0 assert config.get_token(PROFILE) is None @@ -42,7 +44,7 @@ def test_config_load(hass: HomeAssistant) -> None: """Test loading from the file.""" with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", mock_open(read_data=JSON_STRING), ), ): @@ -61,7 +63,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> config = rtm.RememberTheMilkConfiguration(hass) with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", side_effect=side_effect, ), ): @@ -78,7 +80,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None: config = rtm.RememberTheMilkConfiguration(hass) with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", mock_open(read_data="random characters"), ), ): @@ -98,7 +100,9 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None: rtm_id = "3" open_mock = mock_open() config = rtm.RememberTheMilkConfiguration(hass) - with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", open_mock + ): config = rtm.RememberTheMilkConfiguration(hass) assert open_mock.return_value.write.call_count == 0 assert config.get_rtm_id(PROFILE, hass_id) is None From 4f5c7353f8563124cb8e5d368e65171a28ec3b08 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 17:34:17 +0100 Subject: [PATCH 1158/1435] Test remember the milk configurator (#139122) --- .../components/remember_the_milk/conftest.py | 12 +++- tests/components/remember_the_milk/const.py | 5 ++ .../remember_the_milk/test_entity.py | 8 +-- .../components/remember_the_milk/test_init.py | 65 +++++++++++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 tests/components/remember_the_milk/test_init.py diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py index f7257f35c64..ac80cf2972b 100644 --- a/tests/components/remember_the_milk/conftest.py +++ b/tests/components/remember_the_milk/conftest.py @@ -13,8 +13,16 @@ from .const import TOKEN @pytest.fixture(name="client") def client_fixture() -> Generator[MagicMock]: """Create a mock client.""" - with patch("homeassistant.components.remember_the_milk.entity.Rtm") as client_class: - client = client_class.return_value + client = MagicMock() + with ( + patch( + "homeassistant.components.remember_the_milk.entity.Rtm" + ) as entity_client_class, + patch("homeassistant.components.remember_the_milk.Rtm") as client_class, + ): + entity_client_class.return_value = client + client_class.return_value = client + client.token = TOKEN client.token_valid.return_value = True timelines = MagicMock() timelines.timeline.value = "1234" diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index 3f1d0067219..bed39eec5f8 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -3,6 +3,11 @@ import json PROFILE = "myprofile" +CONFIG = { + "name": f"{PROFILE}", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} TOKEN = "mytoken" JSON_STRING = json.dumps( { diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py index e9d7a16d7ab..bdd4189e394 100644 --- a/tests/components/remember_the_milk/test_entity.py +++ b/tests/components/remember_the_milk/test_entity.py @@ -10,13 +10,7 @@ from homeassistant.components.remember_the_milk import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import PROFILE - -CONFIG = { - "name": f"{PROFILE}", - "api_key": "test-api-key", - "shared_secret": "test-shared-secret", -} +from .const import CONFIG, PROFILE @pytest.mark.parametrize( diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py new file mode 100644 index 00000000000..feed2894d86 --- /dev/null +++ b/tests/components/remember_the_milk/test_init.py @@ -0,0 +1,65 @@ +"""Test the Remember The Milk integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.remember_the_milk import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CONFIG, PROFILE, TOKEN + + +@pytest.fixture(autouse=True) +def configure_id() -> Generator[str]: + """Fixture to return a configure_id.""" + mock_id = "1-1" + with patch( + "homeassistant.components.configurator.Configurator._generate_unique_id" + ) as generate_id: + generate_id.return_value = mock_id + yield mock_id + + +@pytest.mark.parametrize( + ("token", "rtm_entity_exists", "configurator_end_state"), + [(TOKEN, True, "configured"), (None, False, "configure")], +) +async def test_configurator( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + configure_id: str, + token: str | None, + rtm_entity_exists: bool, + configurator_end_state: str, +) -> None: + """Test configurator.""" + storage.get_token.return_value = None + client.authenticate_desktop.return_value = ("test-url", "test-frob") + client.token = token + rtm_entity_id = f"{DOMAIN}.{PROFILE}" + configure_entity_id = f"configurator.{DOMAIN}_{PROFILE}" + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + await hass.async_block_till_done() + + assert hass.states.get(rtm_entity_id) is None + state = hass.states.get(configure_entity_id) + assert state + assert state.state == "configure" + + await hass.services.async_call( + "configurator", + "configure", + {"configure_id": configure_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert bool(hass.states.get(rtm_entity_id)) == rtm_entity_exists + state = hass.states.get(configure_entity_id) + assert state + assert state.state == configurator_end_state From 3d507c7b442abd599972008214ada53bea2a867a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 18:40:31 +0100 Subject: [PATCH 1159/1435] Change backup listener calls for existing backup integrations (#138988) --- .../components/google_drive/__init__.py | 19 +++++----------- homeassistant/components/onedrive/__init__.py | 20 ++++++----------- .../components/synology_dsm/__init__.py | 22 ++++++++----------- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index b30bc2ae1f6..d5252bd01ea 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, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) except GoogleDriveApiError as err: raise ConfigEntryNotReady from err - _async_notify_backup_listeners_soon(hass) + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) return True @@ -58,15 +62,4 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleDriveConfigEntry ) -> bool: """Unload a config entry.""" - _async_notify_backup_listeners_soon(hass) return True - - -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) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 6805b073ea2..454c782af92 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -17,7 +17,7 @@ from onedrive_personal_sdk.exceptions import ( from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -102,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_key="failed_to_migrate_files", ) from err - _async_notify_backup_listeners_soon(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: @@ -110,25 +109,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Unload a OneDrive config entry.""" - _async_notify_backup_listeners_soon(hass) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -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) - - 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) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 97095f5d299..1b26b7df84d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -11,7 +11,7 @@ from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -131,7 +131,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_async_update_listener)) if entry.options[CONF_BACKUP_SHARE]: - _async_notify_backup_listeners_soon(hass) + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload( + entry.async_on_state_change(async_notify_backup_listeners) + ) return True @@ -142,20 +149,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] await entry_data.api.async_unload() hass.data[DOMAIN].pop(entry.unique_id) - _async_notify_backup_listeners_soon(hass) return unload_ok -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) - - async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) From 6ad6e82a2306ff09d19e7acfc614a6df5760d1f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Feb 2025 12:41:38 -0600 Subject: [PATCH 1160/1435] Bump thermobeacon-ble to 0.8.0 (#139119) --- homeassistant/components/thermobeacon/manifest.json | 8 +++++++- homeassistant/generated/bluetooth.py | 9 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index ce6a3f71ef3..e060cbd91bf 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -14,6 +14,12 @@ "manufacturer_data_start": [0], "connectable": false }, + { + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 20, + "manufacturer_data_start": [0], + "connectable": false + }, { "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", "manufacturer_id": 21, @@ -48,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.7.0"] + "requirements": ["thermobeacon-ble==0.8.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 447b6d284f0..587fea8b941 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -688,6 +688,15 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "manufacturer_id": 17, "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "thermobeacon", + "manufacturer_data_start": [ + 0, + ], + "manufacturer_id": 20, + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "thermobeacon", diff --git a/requirements_all.txt b/requirements_all.txt index cb03d16903d..04cc0c38d67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2884,7 +2884,7 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro thermopro-ble==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af58c786530..f72da658fb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2321,7 +2321,7 @@ teslemetry-stream==0.6.10 tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro thermopro-ble==0.11.0 From 8f9f9bc8e7ea7cd5f7f233329ac75a4494ed6d96 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 19:59:10 +0100 Subject: [PATCH 1161/1435] Complete remember the milk typing (#139123) --- .strict-typing | 1 + .../components/remember_the_milk/__init__.py | 20 ++++++++++++++----- .../components/remember_the_milk/entity.py | 18 ++++++++++++----- .../components/remember_the_milk/storage.py | 3 ++- mypy.ini | 10 ++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.strict-typing b/.strict-typing index 682e2c920ce..95eb2abb4b4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -407,6 +407,7 @@ homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.* +homeassistant.components.remember_the_milk.* homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.reolink.* diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index fc192bd538a..df9eec0622f 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -75,8 +75,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def _create_instance( - hass, account_name, api_key, shared_secret, token, stored_rtm_config, component -): + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + token: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: entity = RememberTheMilkEntity( account_name, api_key, shared_secret, token, stored_rtm_config ) @@ -96,9 +102,13 @@ def _create_instance( def _register_new_account( - hass, account_name, api_key, shared_secret, stored_rtm_config, component -): - request_id = None + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: api = Rtm(api_key, shared_secret, "write", None) url, frob = api.authenticate_desktop() LOGGER.debug("Sent authentication request to server") diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index bf75debe367..be69d16f72f 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -7,12 +7,20 @@ from homeassistant.core import ServiceCall from homeassistant.helpers.entity import Entity from .const import LOGGER +from .storage import RememberTheMilkConfiguration class RememberTheMilkEntity(Entity): """Representation of an interface to Remember The Milk.""" - def __init__(self, name, api_key, shared_secret, token, rtm_config): + def __init__( + self, + name: str, + api_key: str, + shared_secret: str, + token: str, + rtm_config: RememberTheMilkConfiguration, + ) -> None: """Create new instance of Remember The Milk component.""" self._name = name self._api_key = api_key @@ -20,11 +28,11 @@ class RememberTheMilkEntity(Entity): self._token = token self._rtm_config = rtm_config self._rtm_api = Rtm(api_key, shared_secret, "delete", token) - self._token_valid = None + self._token_valid = False self._check_token() LOGGER.debug("Instance created for account %s", self._name) - def _check_token(self): + def _check_token(self) -> bool: """Check if the API token is still valid. If it is not valid any more, delete it from the configuration. This @@ -127,12 +135,12 @@ class RememberTheMilkEntity(Entity): ) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if not self._token_valid: return "API token invalid" diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py index ae51acd963b..593abb7da2c 100644 --- a/homeassistant/components/remember_the_milk/storage.py +++ b/homeassistant/components/remember_the_milk/storage.py @@ -4,6 +4,7 @@ from __future__ import annotations import json from pathlib import Path +from typing import cast from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant @@ -51,7 +52,7 @@ class RememberTheMilkConfiguration: def get_token(self, profile_name: str) -> str | None: """Get the server token for a profile.""" if profile_name in self._config: - return self._config[profile_name][CONF_TOKEN] + return cast(str, self._config[profile_name][CONF_TOKEN]) return None def set_token(self, profile_name: str, token: str) -> None: diff --git a/mypy.ini b/mypy.ini index 4c062c99aec..a04242dc66d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3826,6 +3826,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.remember_the_milk.*] +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.remote.*] check_untyped_defs = true disallow_incomplete_defs = true From d62c18c225b1d9eb752d50c1c000a83ad7dc689d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 20:06:28 +0100 Subject: [PATCH 1162/1435] Fix flakey onedrive tests (#139129) --- tests/components/onedrive/conftest.py | 68 +++++++++++++++++++----- tests/components/onedrive/const.py | 48 +---------------- tests/components/onedrive/test_backup.py | 7 ++- tests/components/onedrive/test_init.py | 7 +-- 4 files changed, 65 insertions(+), 65 deletions(-) diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 8ff650012f9..74232f2cc39 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 html import escape from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +11,9 @@ from onedrive_personal_sdk.models.items import ( AppRoot, Drive, DriveQuota, + File, Folder, + Hashes, IdentitySet, ItemParentReference, User, @@ -30,15 +33,7 @@ from homeassistant.components.onedrive.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import ( - BACKUP_METADATA, - CLIENT_ID, - CLIENT_SECRET, - IDENTITY_SET, - INSTANCE_ID, - MOCK_BACKUP_FILE, - MOCK_METADATA_FILE, -) +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, IDENTITY_SET, INSTANCE_ID from tests.common import MockConfigEntry @@ -165,20 +160,67 @@ def mock_folder() -> Folder: ) +@pytest.fixture +def mock_backup_file() -> File: + """Return a mocked backup file.""" + return 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", + created_by=IDENTITY_SET, + ) + + +@pytest.fixture +def mock_metadata_file() -> File: + """Return a mocked metadata file.""" + return 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=IDENTITY_SET, + ) + + @pytest.fixture(autouse=True) def mock_onedrive_client( mock_onedrive_client_init: MagicMock, mock_approot: AppRoot, mock_drive: Drive, mock_folder: Folder, + mock_backup_file: File, + mock_metadata_file: File, ) -> 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_folder - client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] + client.list_drive_items.return_value = [mock_backup_file, mock_metadata_file] client.get_drive_item.return_value = mock_folder - client.upload_file.return_value = MOCK_METADATA_FILE + client.upload_file.return_value = mock_metadata_file class MockStreamReader: async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: @@ -193,12 +235,12 @@ def mock_onedrive_client( @pytest.fixture -def mock_large_file_upload_client() -> Generator[AsyncMock]: +def mock_large_file_upload_client(mock_backup_file: File) -> Generator[AsyncMock]: """Return a mocked LargeFileUploadClient upload.""" with patch( "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" ) as mock_upload: - mock_upload.return_value = MOCK_BACKUP_FILE + 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 6e91a7ef0ea..4e67c358179 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -1,15 +1,6 @@ """Consts for OneDrive tests.""" -from html import escape -from json import dumps - -from onedrive_personal_sdk.models.items import ( - File, - Hashes, - IdentitySet, - ItemParentReference, - User, -) +from onedrive_personal_sdk.models.items import IdentitySet, User CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -38,40 +29,3 @@ IDENTITY_SET = IdentitySet( email="john@doe.com", ) ) - -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", - created_by=IDENTITY_SET, -) - -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=IDENTITY_SET, -) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 41ecbdb240f..c307e5190c1 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -11,6 +11,7 @@ from onedrive_personal_sdk.exceptions import ( HashMismatchError, OneDriveException, ) +from onedrive_personal_sdk.models.items import File import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -23,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import setup_integration -from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_METADATA_FILE +from .const import BACKUP_METADATA from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator @@ -248,12 +249,14 @@ async def test_error_on_agents_download( hass_client: ClientSessionGenerator, mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, + mock_backup_file: File, + mock_metadata_file: File, ) -> None: """Test we get not found on an not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] mock_onedrive_client.list_drive_items.side_effect = [ - [MOCK_BACKUP_FILE, MOCK_METADATA_FILE], + [mock_backup_file, mock_metadata_file], [], ] diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 41c1966a4ae..c7765e0a7f8 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -11,7 +11,7 @@ from onedrive_personal_sdk.exceptions import ( NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import AppRoot, Drive, Folder, ItemUpdate +from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder, ItemUpdate import pytest from syrupy import SnapshotAssertion @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import setup_integration -from .const import BACKUP_METADATA, INSTANCE_ID, MOCK_BACKUP_FILE +from .const import BACKUP_METADATA, INSTANCE_ID from tests.common import MockConfigEntry @@ -145,9 +145,10 @@ async def test_migrate_metadata_files( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_backup_file: File, ) -> None: """Test migration of metadata files.""" - MOCK_BACKUP_FILE.description = escape( + mock_backup_file.description = escape( dumps({**BACKUP_METADATA, "metadata_version": 1}) ) await setup_integration(hass, mock_config_entry) From 580c6f26840778669981027664e059a53d05f406 Mon Sep 17 00:00:00 2001 From: SLaks Date: Sun, 23 Feb 2025 19:11:38 -0500 Subject: [PATCH 1163/1435] Allow arbitrary Gemini attachments (#138751) * Gemini: Allow arbitrary attachments This lets me use Gemini to extract information from PDFs, HTML, or other files. * Gemini: Only add deprecation warning when deprecated parameter has a value * Gemini: Use Files.upload() for both images and other files This simplifies the code. Within the Google client, this takes a different codepath (it uploads images as a file instead of re-saving them into inline bytes). I think that's a feature (it's probably more efficient?). * Gemini: Deduplicate filenames --- .../__init__.py | 55 ++++++++++++------- .../services.yaml | 5 ++ .../strings.json | 13 ++++- .../snapshots/test_init.ambr | 3 +- .../test_init.py | 33 ++--------- 5 files changed, 59 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e9ab5cbdd3e..33e361d1433 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -import mimetypes from pathlib import Path from google import genai # type: ignore[attr-defined] from google.genai.errors import APIError, ClientError -from PIL import Image from requests.exceptions import Timeout import voluptuous as vol @@ -26,6 +24,7 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import ( @@ -38,6 +37,7 @@ from .const import ( SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" +CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) @@ -50,31 +50,43 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" + + if call.data[CONF_IMAGE_FILENAME]: + # Deprecated in 2025.3, to remove in 2025.9 + async_create_issue( + hass, + DOMAIN, + "deprecated_image_filename_parameter", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_image_filename_parameter", + ) + prompt_parts = [call.data[CONF_PROMPT]] - def append_images_to_prompt(): - image_filenames = call.data[CONF_IMAGE_FILENAME] - for image_filename in image_filenames: - if not hass.config.is_allowed_path(image_filename): - raise HomeAssistantError( - f"Cannot read `{image_filename}`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ) - if not Path(image_filename).exists(): - raise HomeAssistantError(f"`{image_filename}` does not exist") - mime_type, _ = mimetypes.guess_type(image_filename) - if mime_type is None or not mime_type.startswith("image"): - raise HomeAssistantError(f"`{image_filename}` is not an image") - prompt_parts.append(Image.open(image_filename)) - - await hass.async_add_executor_job(append_images_to_prompt) - config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries( DOMAIN )[0] + client = config_entry.runtime_data + def append_files_to_prompt(): + image_filenames = call.data[CONF_IMAGE_FILENAME] + filenames = call.data[CONF_FILENAMES] + for filename in set(image_filenames + filenames): + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + f"Cannot read `{filename}`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ) + if not Path(filename).exists(): + raise HomeAssistantError(f"`{filename}` does not exist") + prompt_parts.append(client.files.upload(file=filename)) + + await hass.async_add_executor_job(append_files_to_prompt) + try: response = await client.aio.models.generate_content( model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts @@ -105,6 +117,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(CONF_FILENAMES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml index f35697b89f8..82190d64540 100644 --- a/homeassistant/components/google_generative_ai_conversation/services.yaml +++ b/homeassistant/components/google_generative_ai_conversation/services.yaml @@ -9,3 +9,8 @@ generate_content: required: false selector: object: + filenames: + required: false + selector: + text: + multiple: true diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 9fea4805d38..772fadb089c 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -56,10 +56,21 @@ }, "image_filename": { "name": "Image filename", - "description": "Images", + "description": "Deprecated. Use filenames instead.", + "example": "/config/www/image.jpg" + }, + "filenames": { + "name": "Attachment filenames", + "description": "Attachments to add to the prompt (images, PDFs, etc)", "example": "/config/www/image.jpg" } } } + }, + "issues": { + "deprecated_image_filename_parameter": { + "title": "Deprecated 'image_filename' parameter", + "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead." + } } } 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 e2d93611ea6..8e6231cbffd 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -8,7 +8,8 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - b'image bytes', + b'some file', + b'some file', ]), 'model': 'models/gemini-2.0-flash', }), diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index f2e3ac10733..0dad485812e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -66,8 +66,8 @@ async def test_generate_content_service_with_image( ), ) as mock_generate, patch( - "homeassistant.components.google_generative_ai_conversation.Image.open", - return_value=b"image bytes", + "google.genai.files.Files.upload", + return_value=b"some file", ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), @@ -77,7 +77,7 @@ async def test_generate_content_service_with_image( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], }, blocking=True, return_response=True, @@ -161,7 +161,7 @@ async def test_generate_content_service_with_image_not_allowed_path( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, @@ -186,30 +186,7 @@ async def test_generate_content_service_with_image_not_exists( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_non_image(hass: HomeAssistant) -> None: - """Test generate content service with a non image.""" - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("pathlib.Path.exists", return_value=True), - pytest.raises( - HomeAssistantError, match="`doorbell_snapshot.mp4` is not an image" - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.mp4", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, From db5bf417904a77fa2be75e555fac639400599b70 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:37:25 -0500 Subject: [PATCH 1164/1435] bump soco to 0.30.9 (#139143) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index bb3d99c4c93..5bbfc33ae5b 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco", "sonos_websocket"], - "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.9", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 04cc0c38d67..179f82d04c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2754,7 +2754,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.8 +soco==0.30.9 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f72da658fb2..2b15ecf055d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2221,7 +2221,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.8 +soco==0.30.9 # homeassistant.components.solarlog solarlog_cli==0.4.0 From ea1045d826f7ed317ec578e6063bc67fcf20aa99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 08:42:15 +0100 Subject: [PATCH 1165/1435] Bump github/codeql-action from 3.28.9 to 3.28.10 (#139162) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a4469cde0d8..4bdddf50c25 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.9 + uses: github/codeql-action/init@v3.28.10 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.9 + uses: github/codeql-action/analyze@v3.28.10 with: category: "/language:python" From 8c4b8028cf515adbf005691fdf7eba46a1686181 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 24 Feb 2025 09:52:53 +0200 Subject: [PATCH 1166/1435] Bump aiowebostv to 0.7.0 (#139145) --- .../components/webostv/config_flow.py | 8 +- .../components/webostv/diagnostics.py | 18 ++--- homeassistant/components/webostv/helpers.py | 8 +- .../components/webostv/manifest.json | 2 +- .../components/webostv/media_player.py | 67 ++++++++-------- homeassistant/components/webostv/notify.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webostv/conftest.py | 33 ++++---- tests/components/webostv/test_config_flow.py | 6 +- tests/components/webostv/test_media_player.py | 76 +++++++++---------- tests/components/webostv/test_notify.py | 2 +- 12 files changed, 117 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index fbc3eb958dd..80c8fb7f8f2 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -92,13 +92,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: await self.async_set_unique_id( - client.hello_info["deviceUUID"], raise_on_progress=False + client.tv_info.hello["deviceUUID"], raise_on_progress=False ) self._abort_if_unique_id_configured({CONF_HOST: self._host}) data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} if not self._name: - self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}" + self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) @@ -176,7 +176,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(client.hello_info["deviceUUID"]) + await self.async_set_unique_id(client.tv_info.hello["deviceUUID"]) self._abort_if_unique_id_mismatch(reason="wrong_device") data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} return self.async_update_reload_and_abort(reconfigure_entry, data=data) @@ -214,7 +214,7 @@ class OptionsFlowHandler(OptionsFlow): sources_list = [] try: client = await async_control_connect(self.hass, self.host, self.key) - sources_list = get_sources(client) + sources_list = get_sources(client.tv_state) except WebOsTvPairError: errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 7fb64a2cb8f..393a6a066ff 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -32,15 +32,15 @@ async def async_get_config_entry_diagnostics( client_data = { "is_registered": client.is_registered(), "is_connected": client.is_connected(), - "current_app_id": client.current_app_id, - "current_channel": client.current_channel, - "apps": client.apps, - "inputs": client.inputs, - "system_info": client.system_info, - "software_info": client.software_info, - "hello_info": client.hello_info, - "sound_output": client.sound_output, - "is_on": client.is_on, + "current_app_id": client.tv_state.current_app_id, + "current_channel": client.tv_state.current_channel, + "apps": client.tv_state.apps, + "inputs": client.tv_state.inputs, + "system_info": client.tv_info.system, + "software_info": client.tv_info.software, + "hello_info": client.tv_info.hello, + "sound_output": client.tv_state.sound_output, + "is_on": client.tv_state.is_on, } return async_redact_data( diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index 3c509a56d1e..f70f250f91d 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from aiowebostv import WebOsClient +from aiowebostv import WebOsClient, WebOsTvState from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST @@ -83,16 +83,16 @@ def async_get_client_by_device_entry( ) -def get_sources(client: WebOsClient) -> list[str]: +def get_sources(tv_state: WebOsTvState) -> list[str]: """Construct sources list.""" sources = [] found_live_tv = False - for app in client.apps.values(): + for app in tv_state.apps.values(): sources.append(app["title"]) if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - for source in client.inputs.values(): + for source in tv_state.inputs.values(): sources.append(source["label"]) if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 5fbcf759ee3..45c9628539c 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.6.2"], + "requirements": ["aiowebostv==0.7.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 33c09aa8708..780e9f418a5 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -11,7 +11,7 @@ from http import HTTPStatus import logging from typing import Any, Concatenate, cast -from aiowebostv import WebOsClient, WebOsTvPairError +from aiowebostv import WebOsTvPairError, WebOsTvState import voluptuous as vol from homeassistant import util @@ -205,51 +205,52 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_handle_state_update(self, _client: WebOsClient) -> None: + async def async_handle_state_update(self, tv_state: WebOsTvState) -> None: """Update state from WebOsClient.""" self._update_states() self.async_write_ha_state() def _update_states(self) -> None: """Update entity state attributes.""" + tv_state = self._client.tv_state self._update_sources() self._attr_state = ( - MediaPlayerState.ON if self._client.is_on else MediaPlayerState.OFF + MediaPlayerState.ON if tv_state.is_on else MediaPlayerState.OFF ) - self._attr_is_volume_muted = cast(bool, self._client.muted) + self._attr_is_volume_muted = cast(bool, tv_state.muted) self._attr_volume_level = None - if self._client.volume is not None: - self._attr_volume_level = self._client.volume / 100.0 + if tv_state.volume is not None: + self._attr_volume_level = tv_state.volume / 100.0 self._attr_source = self._current_source self._attr_source_list = sorted(self._source_list) self._attr_media_content_type = None - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._attr_media_content_type = MediaType.CHANNEL self._attr_media_title = None - if (self._client.current_app_id == LIVE_TV_APP_ID) and ( - self._client.current_channel is not None + if (tv_state.current_app_id == LIVE_TV_APP_ID) and ( + tv_state.current_channel is not None ): self._attr_media_title = cast( - str, self._client.current_channel.get("channelName") + str, tv_state.current_channel.get("channelName") ) self._attr_media_image_url = None - if self._client.current_app_id in self._client.apps: - icon: str = self._client.apps[self._client.current_app_id]["largeIcon"] + if tv_state.current_app_id in tv_state.apps: + icon: str = tv_state.apps[tv_state.current_app_id]["largeIcon"] if not icon.startswith("http"): - icon = self._client.apps[self._client.current_app_id]["icon"] + icon = tv_state.apps[tv_state.current_app_id]["icon"] self._attr_media_image_url = icon if self.state != MediaPlayerState.OFF or not self._supported_features: supported = SUPPORT_WEBOSTV - if self._client.sound_output == "external_speaker": + if tv_state.sound_output == "external_speaker": supported = supported | SUPPORT_WEBOSTV_VOLUME - elif self._client.sound_output != "lineout": + elif tv_state.sound_output != "lineout": supported = ( supported | SUPPORT_WEBOSTV_VOLUME @@ -265,9 +266,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) self._attr_assumed_state = True - if self._client.is_on and self._client.media_state: + if tv_state.is_on and tv_state.media_state: self._attr_assumed_state = False - for entry in self._client.media_state: + for entry in tv_state.media_state: if entry.get("playState") == "playing": self._attr_state = MediaPlayerState.PLAYING elif entry.get("playState") == "paused": @@ -275,35 +276,37 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): elif entry.get("playState") == "unloaded": self._attr_state = MediaPlayerState.IDLE + tv_info = self._client.tv_info if self.state != MediaPlayerState.OFF: - maj_v = self._client.software_info.get("major_ver") - min_v = self._client.software_info.get("minor_ver") + maj_v = tv_info.software.get("major_ver") + min_v = tv_info.software.get("minor_ver") if maj_v and min_v: self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" - if model := self._client.system_info.get("modelName"): + if model := tv_info.system.get("modelName"): self._attr_device_info["model"] = model - if serial_number := self._client.system_info.get("serialNumber"): + if serial_number := tv_info.system.get("serialNumber"): self._attr_device_info["serial_number"] = serial_number self._attr_extra_state_attributes = {} - if self._client.sound_output is not None or self.state != MediaPlayerState.OFF: + if tv_state.sound_output is not None or self.state != MediaPlayerState.OFF: self._attr_extra_state_attributes = { - ATTR_SOUND_OUTPUT: self._client.sound_output + ATTR_SOUND_OUTPUT: tv_state.sound_output } def _update_sources(self) -> None: """Update list of sources from current source, apps, inputs and configured list.""" + tv_state = self._client.tv_state source_list = self._source_list self._source_list = {} conf_sources = self._sources found_live_tv = False - for app in self._client.apps.values(): + for app in tv_state.apps.values(): if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - if app["id"] == self._client.current_app_id: + if app["id"] == tv_state.current_app_id: self._current_source = app["title"] self._source_list[app["title"]] = app elif ( @@ -314,10 +317,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ): self._source_list[app["title"]] = app - for source in self._client.inputs.values(): + for source in tv_state.inputs.values(): if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True - if source["appId"] == self._client.current_app_id: + if source["appId"] == tv_state.current_app_id: self._current_source = source["label"] self._source_list[source["label"]] = source elif ( @@ -334,7 +337,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): # not appear in the app or input lists in some cases elif not found_live_tv: app = {"id": LIVE_TV_APP_ID, "title": "Live TV"} - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._current_source = app["title"] self._source_list["Live TV"] = app elif ( @@ -434,12 +437,12 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) - if media_type == MediaType.CHANNEL and self._client.channels: + if media_type == MediaType.CHANNEL and self._client.tv_state.channels: _LOGGER.debug("Searching channel") partial_match_channel_id = None perfect_match_channel_id = None - for channel in self._client.channels: + for channel in self._client.tv_state.channels: if media_id == channel["channelNumber"]: perfect_match_channel_id = channel["channelId"] continue @@ -484,7 +487,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_next_track(self) -> None: """Send next track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_up() else: await self._client.fast_forward() @@ -492,7 +495,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_previous_track(self) -> None: """Send the previous track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_down() else: await self._client.rewind() diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 2393cb4cd07..3966cea5e92 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -49,7 +49,7 @@ class LgWebOSNotificationService(BaseNotificationService): data = kwargs[ATTR_DATA] icon_path = data.get(ATTR_ICON) if data else None - if not client.is_on: + if not client.tv_state.is_on: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="notify_device_off", diff --git a/requirements_all.txt b/requirements_all.txt index 179f82d04c1..7c9d90ad8df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.2 +aiowebostv==0.7.0 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b15ecf055d..b9a7579d7f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.2 +aiowebostv==0.7.0 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index c6594746cc5..7fbd8d667e2 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from aiowebostv import WebOsTvInfo, WebOsTvState import pytest from homeassistant.components.webostv.const import LIVE_TV_APP_ID @@ -40,26 +41,30 @@ def client_fixture(): ), ): client = mock_client_class.return_value - client.hello_info = {"deviceUUID": FAKE_UUID} - client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": TV_MODEL, "serialNumber": "1234567890"} + client.tv_info = WebOsTvInfo( + hello={"deviceUUID": FAKE_UUID}, + system={"modelName": TV_MODEL, "serialNumber": "1234567890"}, + software={"major_ver": "major", "minor_ver": "minor"}, + ) client.client_key = CLIENT_KEY - client.apps = MOCK_APPS - client.inputs = MOCK_INPUTS - client.current_app_id = LIVE_TV_APP_ID + client.tv_state = WebOsTvState( + apps=MOCK_APPS, + inputs=MOCK_INPUTS, + current_app_id=LIVE_TV_APP_ID, + channels=[CHANNEL_1, CHANNEL_2], + current_channel=CHANNEL_1, + volume=37, + sound_output="speaker", + muted=False, + is_on=True, + media_state=[{"playState": ""}], + ) - client.channels = [CHANNEL_1, CHANNEL_2] - client.current_channel = CHANNEL_1 - - client.volume = 37 - client.sound_output = "speaker" - client.muted = False - client.is_on = True client.is_registered = Mock(return_value=True) client.is_connected = Mock(return_value=True) async def mock_state_update_callback(): - await client.register_state_update_callback.call_args[0][0](client) + await client.register_state_update_callback.call_args[0][0](client.tv_state) client.mock_state_update = AsyncMock(side_effect=mock_state_update_callback) diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 34ab39618d8..564ff9afa9b 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -84,8 +84,8 @@ async def test_options_flow_live_tv_in_apps( hass: HomeAssistant, client, apps, inputs ) -> None: """Test options config flow Live TV found in apps.""" - client.apps = apps - client.inputs = inputs + client.tv_state.apps = apps + client.tv_state.inputs = inputs entry = await setup_webostv(hass) result = await hass.config_entries.options.async_init(entry.entry_id) @@ -411,7 +411,7 @@ async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - client.hello_info = {"deviceUUID": "wrong_uuid"} + client.tv_info.hello = {"deviceUUID": "wrong_uuid"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "new_host"}, diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 679092efe3b..59e3fc68cf7 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -156,7 +156,7 @@ async def test_media_next_previous_track( getattr(client, client_call[1]).assert_called_once() # check next/previous for not Live TV channels - client.current_app_id = "in1" + client.tv_state.current_app_id = "in1" data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call(MP_DOMAIN, service, data, True) @@ -303,8 +303,8 @@ async def test_device_info_startup_off( hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" - client.system_info = None - client.is_on = False + client.tv_info.system = {} + client.tv_state.is_on = False entry = await setup_webostv(hass) await client.mock_state_update() @@ -335,14 +335,14 @@ async def test_entity_attributes( assert state == snapshot(exclude=props("entity_picture")) # Volume level not available - client.volume = None + client.tv_state.volume = None await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None # Channel change - client.current_channel = CHANNEL_2 + client.tv_state.current_channel = CHANNEL_2 await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes @@ -353,8 +353,8 @@ async def test_entity_attributes( assert device == snapshot # Sound output when off - client.sound_output = None - client.is_on = False + client.tv_state.sound_output = None + client.tv_state.is_on = False await client.mock_state_update() state = hass.states.get(ENTITY_ID) @@ -410,13 +410,13 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 3 # Live TV is current app - client.apps = { + client.tv_state.apps = { LIVE_TV_APP_ID: { "title": "Live TV", "id": "some_id", }, } - client.current_app_id = "some_id" + client.tv_state.current_app_id = "some_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -424,7 +424,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 3 # Live TV is is in inputs - client.inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", @@ -438,7 +438,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV is current input - client.inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", @@ -452,7 +452,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV not found - client.current_app_id = "other_id" + client.tv_state.current_app_id = "other_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -460,8 +460,8 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV not found in sources/apps but is current app - client.apps = {} - client.current_app_id = LIVE_TV_APP_ID + client.tv_state.apps = {} + client.tv_state.current_app_id = LIVE_TV_APP_ID await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -469,7 +469,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Bad update, keep old update - client.inputs = {} + client.tv_state.inputs = {} await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -543,7 +543,7 @@ async def test_control_error_handling( """Test control errors handling.""" await setup_webostv(hass) client.play.side_effect = exception - client.is_on = is_on + client.tv_state.is_on = is_on await client.mock_state_update() data = {ATTR_ENTITY_ID: ENTITY_ID} @@ -566,7 +566,7 @@ async def test_turn_off_when_device_is_off(hass: HomeAssistant, client) -> None: async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - client.sound_output = "lineout" + client.tv_state.sound_output = "lineout" await setup_webostv(hass) await client.mock_state_update() @@ -577,7 +577,7 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step - client.sound_output = "external_speaker" + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME attrs = hass.states.get(ENTITY_ID).attributes @@ -585,7 +585,7 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step, set - client.sound_output = "speaker" + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.VOLUME_SET attrs = hass.states.get(ENTITY_ID).attributes @@ -623,8 +623,8 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: async def test_cached_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None supported = ( SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.TURN_ON ) @@ -652,8 +652,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: ) # TV on, support volume mute, step - client.is_on = True - client.sound_output = "external_speaker" + client.tv_state.is_on = True + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -662,8 +662,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -672,8 +672,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV on, support volume mute, step, set - client.is_on = True - client.sound_output = "speaker" + client.tv_state.is_on = True + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = ( @@ -684,8 +684,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step, set - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = ( @@ -728,8 +728,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: async def test_supported_features_no_cache(hass: HomeAssistant, client) -> None: """Test supported features if device is off and no cache.""" - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await setup_webostv(hass) supported = ( @@ -772,7 +772,7 @@ async def test_get_image_http( ) -> None: """Test get image via http.""" url = "http://something/valid_icon" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -797,7 +797,7 @@ async def test_get_image_http_error( ) -> None: """Test get image via http error.""" url = "http://something/icon_error" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -823,7 +823,7 @@ async def test_get_image_https( ) -> None: """Test get image via http.""" url = "https://something/valid_icon_https" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -871,18 +871,18 @@ async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - client.media_state = [{"playState": "playing"}] + client.tv_state.media_state = [{"playState": "playing"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - client.media_state = [{"playState": "paused"}] + client.tv_state.media_state = [{"playState": "paused"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - client.media_state = [{"playState": "unloaded"}] + client.tv_state.media_state = [{"playState": "unloaded"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE - client.is_on = False + client.tv_state.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index fd56f0ea0bb..e64d58b8f91 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -104,7 +104,7 @@ async def test_errors( ) -> None: """Test error scenarios.""" await setup_webostv(hass) - client.is_on = is_on + client.tv_state.is_on = is_on assert hass.services.has_service("notify", SERVICE_NAME) From 183bbcd1e196f80bfeae2916a4eaffedf5df3d64 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 23 Feb 2025 23:53:23 -0800 Subject: [PATCH 1167/1435] Bump androidtvremote2 to 0.2.0 (#139141) --- homeassistant/components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index d9c2dd05c44..1c45e825359 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.1.2"], + "requirements": ["androidtvremote2==0.2.0"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c9d90ad8df..d8e24dcc73b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,7 +461,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9a7579d7f1..3c8f2a803fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anova anova-wifi==0.17.0 From 8c42db7501afa55535c0a0ce388369693885e716 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 09:12:35 +0100 Subject: [PATCH 1168/1435] Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#139161) --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 22 +++++++++++----------- .github/workflows/wheels.yml | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ffefee0d84e..88f6f37d6d6 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6eafa360e83..2aead92791a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -537,7 +537,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -661,7 +661,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -877,7 +877,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest_buckets path: pytest_buckets.txt @@ -980,14 +980,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1108,7 +1108,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1116,7 +1116,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1239,7 +1239,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1247,7 +1247,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1382,14 +1382,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 41e7b351184..743ae869ab9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 7f494c235c52938156d7d7a3d671528bc5f0ded0 Mon Sep 17 00:00:00 2001 From: Philipp S Date: Mon, 24 Feb 2025 09:28:23 +0100 Subject: [PATCH 1169/1435] Consider the zone radius in proximity distance calculation (#138819) * Fix proximity distance calculation The distance is now calculated to the edge of the zone instead of the centre * Adjust proximity test expectations to corrected distance calculation * Add proximity tests for zone changes * Improve comment on proximity distance calculation Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> * Apply suggestions from code review --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- .../components/proximity/coordinator.py | 11 +- .../proximity/snapshots/test_diagnostics.ambr | 8 +- tests/components/proximity/test_init.py | 150 ++++++++++++++---- 3 files changed, 133 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 055c15125f1..856138c9051 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -164,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) return None - distance_to_zone = distance( + distance_to_centre = distance( zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE], latitude, @@ -172,8 +172,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # it is ensured, that distance can't be None, since zones must have lat/lon coordinates - assert distance_to_zone is not None - return round(distance_to_zone) + assert distance_to_centre is not None + + zone_radius: float = zone.attributes["radius"] + if zone_radius > distance_to_centre: + # we've arrived the zone + return 0 + return round(distance_to_centre - zone_radius) def _calc_direction_of_travel( self, diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 42ec74710f9..f6cd4393511 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -5,19 +5,19 @@ 'entities': dict({ 'device_tracker.test1': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'is_in_ignored_zone': False, 'name': 'test1', }), 'device_tracker.test2': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test2', }), 'device_tracker.test3': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test3', }), @@ -42,7 +42,7 @@ }), 'proximity': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'nearest': 'test1', }), 'tracked_states': dict({ diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 22a546e6abe..e9340014207 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -128,7 +128,7 @@ async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -152,7 +152,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -169,7 +169,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -193,7 +193,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -210,7 +210,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "towards" @@ -272,7 +272,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -289,7 +289,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "stationary" @@ -360,7 +360,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -383,13 +383,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -432,7 +432,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -449,13 +449,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -489,7 +489,7 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -562,7 +562,7 @@ async def test_device_tracker_test1_awayfurther_test2_first( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -602,7 +602,7 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -625,13 +625,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "989156" + assert state.state == "989146" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -648,13 +648,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "1364567" + assert state.state == "1364557" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -693,15 +693,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "5176058" + assert state.state == "5176048" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "away_from" @@ -715,15 +715,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -737,15 +737,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -919,3 +919,95 @@ async def test_tracked_zone_is_removed(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNAVAILABLE + + +async def test_tracked_zone_radius_is_changed(hass: HomeAssistant) -> None: + """Test that radius of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.10000001, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change radius of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 2.1, "longitude": 1.1, "radius": 110}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + radius = hass.states.get("zone.home").attributes["radius"] + assert radius == 110 + + # check sensor entities after radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218642" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_tracked_zone_location_is_changed(hass: HomeAssistant) -> None: + """Test that gps location of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change location of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 10, "longitude": 5, "radius": 10}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + latitude = hass.states.get("zone.home").attributes["latitude"] + assert latitude == 10 + longitude = hass.states.get("zone.home").attributes["longitude"] + assert longitude == 5 + + # check sensor entities after location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "1244478" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN From 257242e6e3b5f94a0483b189a9aeb660960a3609 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 24 Feb 2025 17:37:25 +0900 Subject: [PATCH 1170/1435] Remove unnecessary min/max setting of WATER_HEATER (#138969) Remove unnecessary min/max setting Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/number.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index 0cbfcf9b5c8..7003519e0ce 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -118,16 +118,7 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] = DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS, DeviceType.WASHTOWER: WASHER_NUMBERS, DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS, - DeviceType.WATER_HEATER: ( - NumberEntityDescription( - key=ThinQProperty.TARGET_TEMPERATURE, - native_max_value=60, - native_min_value=35, - native_step=1, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - translation_key=ThinQProperty.TARGET_TEMPERATURE, - ), - ), + DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],), DeviceType.WINE_CELLAR: ( NUMBER_DESC[ThinQProperty.LIGHT_STATUS], NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE], @@ -179,7 +170,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity): ) is not None: self._attr_native_unit_of_measurement = unit_of_measurement - # Undate range. + # Update range. if ( self.entity_description.native_min_value is None and (min_value := self.data.min) is not None From fc8affd243968d02782dff70d98a644dccf22df8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 12:33:14 +0100 Subject: [PATCH 1171/1435] Remove setup of rpi_power from onboarding (#139168) * Remove setup of rpi_power from onboarding * Remove test --- .../components/onboarding/manifest.json | 2 +- homeassistant/components/onboarding/views.py | 11 -------- tests/components/onboarding/test_views.py | 26 ------------------- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 8e253d4bff9..3634894cd00 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,7 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["backup", "hassio"], + "after_dependencies": ["backup"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index ea955987d80..b392c6b57b0 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -29,7 +29,6 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component @@ -224,16 +223,6 @@ class CoreConfigOnboardingView(_BaseOnboardingView): "shopping_list", ] - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import hassio - - if ( - is_hassio(hass) - and (core_info := hassio.get_core_info(hass)) - and "raspberrypi" in core_info["machine"] - ): - onboard_integrations.append("rpi_power") - for domain in onboard_integrations: # Create tasks so onboarding isn't affected # by errors in these integrations. diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 99623cb6efe..08d21a13331 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -529,32 +529,6 @@ async def test_onboarding_core_sets_up_radio_browser( assert len(hass.config_entries.async_entries("radio_browser")) == 1 -async def test_onboarding_core_sets_up_rpi_power( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - rpi, - mock_default_integrations, -) -> None: - """Test that the core step sets up rpi_power on RPi.""" - mock_storage(hass_storage, {"done": [const.STEP_USER]}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - resp = await client.post("/api/onboarding/core_config") - - assert resp.status == 200 - - await hass.async_block_till_done() - - rpi_power_state = hass.states.get("binary_sensor.rpi_power_status") - assert rpi_power_state - - async def test_onboarding_core_no_rpi_power( hass: HomeAssistant, hass_storage: dict[str, Any], From d9eb248e91c11bdec4173f65ccf4734c8122aee5 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:23:39 +0100 Subject: [PATCH 1172/1435] Better handle runtime recovery mode in bootstrap (#138624) * Better handle runtime recovery mode in bootstrap * Add test --- homeassistant/bootstrap.py | 66 ++++++++++++++++++++------------------ tests/test_bootstrap.py | 7 +++- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7c5cb7dce4c..9cfc1c95d8b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -328,10 +328,10 @@ async def async_setup_hass( block_async_io.enable() - config_dict = None - basic_setup_success = False - if not (recovery_mode := runtime_config.recovery_mode): + config_dict = None + basic_setup_success = False + await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: @@ -349,39 +349,43 @@ async def async_setup_hass( await async_from_config_dict(config_dict, hass) is not None ) - if config_dict is None: - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + if config_dict is None: + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - elif not basic_setup_success: - _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + elif not basic_setup_success: + _LOGGER.warning( + "Unable to set up core integrations. Activating recovery mode" + ) + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS): - _LOGGER.warning( - "Detected that %s did not load. Activating recovery mode", - ",".join(CRITICAL_INTEGRATIONS), - ) + elif any( + domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS + ): + _LOGGER.warning( + "Detected that %s did not load. Activating recovery mode", + ",".join(CRITICAL_INTEGRATIONS), + ) - old_config = hass.config - old_logging = hass.data.get(DATA_LOGGING) + old_config = hass.config + old_logging = hass.data.get(DATA_LOGGING) - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - if old_logging: - hass.data[DATA_LOGGING] = old_logging - hass.config.debug = old_config.debug - hass.config.skip_pip = old_config.skip_pip - hass.config.skip_pip_packages = old_config.skip_pip_packages - hass.config.internal_url = old_config.internal_url - hass.config.external_url = old_config.external_url - # Setup loader cache after the config dir has been set - loader.async_setup(hass) + if old_logging: + hass.data[DATA_LOGGING] = old_logging + hass.config.debug = old_config.debug + hass.config.skip_pip = old_config.skip_pip + hass.config.skip_pip_packages = old_config.skip_pip_packages + hass.config.internal_url = old_config.internal_url + hass.config.external_url = old_config.external_url + # Setup loader cache after the config dir has been set + loader.async_setup(hass) if recovery_mode: _LOGGER.info("Starting in recovery mode") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d554ca9449a..0d7c8614c6f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant import bootstrap, config as config_util, loader, runner +from homeassistant import bootstrap, config as config_util, core, loader, runner from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( BASE_PLATFORMS, @@ -787,6 +787,9 @@ async def test_setup_hass_recovery_mode( ) -> None: """Test it works.""" with ( + patch( + "homeassistant.core.HomeAssistant", wraps=core.HomeAssistant + ) as mock_hass, patch("homeassistant.components.browser.setup") as browser_setup, patch( "homeassistant.config_entries.ConfigEntries.async_domains", @@ -805,6 +808,8 @@ async def test_setup_hass_recovery_mode( ), ) + mock_hass.assert_called_once() + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 From 571349e3a28dab5704477833e9ceed54dcf482de Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 24 Feb 2025 07:45:10 -0500 Subject: [PATCH 1173/1435] Add Snoo integration (#134243) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/snoo/__init__.py | 63 ++++++++++ homeassistant/components/snoo/config_flow.py | 68 ++++++++++ homeassistant/components/snoo/const.py | 3 + homeassistant/components/snoo/coordinator.py | 39 ++++++ homeassistant/components/snoo/entity.py | 37 ++++++ homeassistant/components/snoo/manifest.json | 11 ++ .../components/snoo/quality_scale.yaml | 72 +++++++++++ homeassistant/components/snoo/sensor.py | 71 +++++++++++ homeassistant/components/snoo/strings.json | 44 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/snoo/__init__.py | 38 ++++++ tests/components/snoo/conftest.py | 73 +++++++++++ tests/components/snoo/const.py | 34 +++++ tests/components/snoo/test_config_flow.py | 118 ++++++++++++++++++ tests/components/snoo/test_init.py | 14 +++ 19 files changed, 700 insertions(+) create mode 100644 homeassistant/components/snoo/__init__.py create mode 100644 homeassistant/components/snoo/config_flow.py create mode 100644 homeassistant/components/snoo/const.py create mode 100644 homeassistant/components/snoo/coordinator.py create mode 100644 homeassistant/components/snoo/entity.py create mode 100644 homeassistant/components/snoo/manifest.json create mode 100644 homeassistant/components/snoo/quality_scale.yaml create mode 100644 homeassistant/components/snoo/sensor.py create mode 100644 homeassistant/components/snoo/strings.json create mode 100644 tests/components/snoo/__init__.py create mode 100644 tests/components/snoo/conftest.py create mode 100644 tests/components/snoo/const.py create mode 100644 tests/components/snoo/test_config_flow.py create mode 100644 tests/components/snoo/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 6a66c24c7e8..3397948d7c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1413,6 +1413,8 @@ build.json @home-assistant/supervisor /tests/components/snapcast/ @luar123 /homeassistant/components/snmp/ @nmaggioni /tests/components/snmp/ @nmaggioni +/homeassistant/components/snoo/ @Lash-L +/tests/components/snoo/ @Lash-L /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck @bdraco diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py new file mode 100644 index 00000000000..aaf0c828830 --- /dev/null +++ b/homeassistant/components/snoo/__init__.py @@ -0,0 +1,63 @@ +"""The Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import asyncio +import logging + +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException, SnooDeviceError +from python_snoo.snoo import Snoo + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import SnooConfigEntry, SnooCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Set up Happiest Baby Snoo from a config entry.""" + + snoo = Snoo( + email=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + clientsession=async_get_clientsession(hass), + ) + + try: + await snoo.authorize() + except (SnooAuthException, InvalidSnooAuth) as ex: + raise ConfigEntryNotReady from ex + try: + devices = await snoo.get_devices() + except SnooDeviceError as ex: + raise ConfigEntryNotReady from ex + coordinators: dict[str, SnooCoordinator] = {} + tasks = [] + for device in devices: + coordinators[device.serialNumber] = SnooCoordinator(hass, device, snoo) + tasks.append(coordinators[device.serialNumber].setup()) + await asyncio.gather(*tasks) + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Unload a config entry.""" + disconnects = await asyncio.gather( + *(coordinator.snoo.disconnect() for coordinator in entry.runtime_data.values()), + return_exceptions=True, + ) + for disconnect in disconnects: + if isinstance(disconnect, Exception): + _LOGGER.warning( + "Failed to disconnect a logger with exception: %s", disconnect + ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/snoo/config_flow.py b/homeassistant/components/snoo/config_flow.py new file mode 100644 index 00000000000..986ef6a0071 --- /dev/null +++ b/homeassistant/components/snoo/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for the Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException +from python_snoo.snoo import Snoo +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class SnooConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Happiest Baby Snoo.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + hub = Snoo( + email=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + clientsession=async_get_clientsession(self.hass), + ) + + try: + tokens = await hub.authorize() + except SnooAuthException: + errors["base"] = "cannot_connect" + except InvalidSnooAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception %s") + errors["base"] = "unknown" + else: + user_uuid = jwt.decode( + tokens.aws_access, options={"verify_signature": False} + )["username"] + await self.async_set_unique_id(user_uuid) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/snoo/const.py b/homeassistant/components/snoo/const.py new file mode 100644 index 00000000000..ff8afe25056 --- /dev/null +++ b/homeassistant/components/snoo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Happiest Baby Snoo integration.""" + +DOMAIN = "snoo" diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py new file mode 100644 index 00000000000..bc06d20955c --- /dev/null +++ b/homeassistant/components/snoo/coordinator.py @@ -0,0 +1,39 @@ +"""Support for Snoo Coordinators.""" + +import logging + +from python_snoo.containers import SnooData, SnooDevice +from python_snoo.snoo import Snoo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +type SnooConfigEntry = ConfigEntry[dict[str, SnooCoordinator]] + +_LOGGER = logging.getLogger(__name__) + + +class SnooCoordinator(DataUpdateCoordinator[SnooData]): + """Snoo coordinator.""" + + config_entry: SnooConfigEntry + + def __init__(self, hass: HomeAssistant, device: SnooDevice, snoo: Snoo) -> None: + """Set up Snoo Coordinator.""" + super().__init__( + hass, + name=device.name, + logger=_LOGGER, + ) + self.device_unique_id = device.serialNumber + self.device = device + self.sensor_data_set: bool = False + self.snoo = snoo + + async def setup(self) -> None: + """Perform setup needed on every coordintaor creation.""" + await self.snoo.subscribe(self.device, self.async_set_updated_data) + # After we subscribe - get the status so that we have something to start with. + # We only need to do this once. The device will auto update otherwise. + await self.snoo.get_status(self.device) diff --git a/homeassistant/components/snoo/entity.py b/homeassistant/components/snoo/entity.py new file mode 100644 index 00000000000..25f54344674 --- /dev/null +++ b/homeassistant/components/snoo/entity.py @@ -0,0 +1,37 @@ +"""Base entity for the Snoo integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SnooCoordinator + + +class SnooDescriptionEntity(CoordinatorEntity[SnooCoordinator]): + """Defines an Snoo entity that uses a description.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: SnooCoordinator, description: EntityDescription + ) -> None: + """Initialize the Snoo entity.""" + super().__init__(coordinator) + self.device = coordinator.device + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_unique_id)}, + name=self.device.name, + manufacturer="Happiest Baby", + model="Snoo", + serial_number=self.device.serialNumber, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data is not None and super().available diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json new file mode 100644 index 00000000000..3dca8cfe7dd --- /dev/null +++ b/homeassistant/components/snoo/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "snoo", + "name": "Happiest Baby Snoo", + "codeowners": ["@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/snoo", + "iot_class": "cloud_push", + "loggers": ["snoo"], + "quality_scale": "bronze", + "requirements": ["python-snoo==0.6.0"] +} diff --git a/homeassistant/components/snoo/quality_scale.yaml b/homeassistant/components/snoo/quality_scale.yaml new file mode 100644 index 00000000000..f10bccb131a --- /dev/null +++ b/homeassistant/components/snoo/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: done + comment: | + There are no common patterns currenty. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + 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: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/snoo/sensor.py b/homeassistant/components/snoo/sensor.py new file mode 100644 index 00000000000..e45b2b88592 --- /dev/null +++ b/homeassistant/components/snoo/sensor.py @@ -0,0 +1,71 @@ +"""Support for Snoo Sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData, SnooStates + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + StateType, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSensorEntityDescription(SensorEntityDescription): + """Describes a Snoo sensor.""" + + value_fn: Callable[[SnooData], StateType] + + +SENSOR_DESCRIPTIONS: list[SnooSensorEntityDescription] = [ + SnooSensorEntityDescription( + key="state", + translation_key="state", + value_fn=lambda data: data.state_machine.state.name, + device_class=SensorDeviceClass.ENUM, + options=[e.name for e in SnooStates], + ), + SnooSensorEntityDescription( + key="time_left", + translation_key="time_left", + value_fn=lambda data: data.state_machine.time_left_timestamp, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSensor(coordinator, description) + for coordinator in coordinators.values() + for description in SENSOR_DESCRIPTIONS + ) + + +class SnooSensor(SnooDescriptionEntity, SensorEntity): + """A sensor using Snoo coordinator.""" + + entity_description: SnooSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json new file mode 100644 index 00000000000..567fa30fca7 --- /dev/null +++ b/homeassistant/components/snoo/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your Snoo username or email", + "password": "Your Snoo password" + } + } + }, + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "state": { + "name": "State", + "state": { + "baseline": "Baseline", + "level1": "Level 1", + "level2": "Level 2", + "level3": "Level 3", + "level4": "Level 4", + "stop": "Stopped", + "pretimeout": "Pre-timeout", + "timeout": "Timeout" + } + }, + "time_left": { + "name": "Time left" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 40af1df86cd..c92235aae47 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -575,6 +575,7 @@ FLOWS = { "smlight", "sms", "snapcast", + "snoo", "snooz", "solaredge", "solarlog", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2d28d4f46d7..6f4315c43dc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5916,6 +5916,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "snoo": { + "name": "Happiest Baby Snoo", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "snooz": { "name": "Snooz", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index d8e24dcc73b..50c4ad93559 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2463,6 +2463,9 @@ python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 +# homeassistant.components.snoo +python-snoo==0.6.0 + # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c8f2a803fb..a1c713424b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1996,6 +1996,9 @@ python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 +# homeassistant.components.snoo +python-snoo==0.6.0 + # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py new file mode 100644 index 00000000000..f8529251720 --- /dev/null +++ b/tests/components/snoo/__init__.py @@ -0,0 +1,38 @@ +"""Tests for the Happiest Baby Snoo integration.""" + +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def create_entry( + hass: HomeAssistant, +) -> ConfigEntry: + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "sample", + }, + # This is also gotten from the fake jwt + unique_id="123e4567-e89b-12d3-a456-426614174000", + version=1, + ) + entry.add_to_hass(hass) + return entry + + +async def async_init_integration(hass: HomeAssistant) -> ConfigEntry: + """Set up the Snoo integration in Home Assistant.""" + + entry = create_entry(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/snoo/conftest.py b/tests/components/snoo/conftest.py new file mode 100644 index 00000000000..33642e67ff5 --- /dev/null +++ b/tests/components/snoo/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Happiest Baby Snoo tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_snoo.containers import SnooDevice +from python_snoo.snoo import Snoo + +from .const import MOCK_AMAZON_AUTH, MOCK_SNOO_AUTH, MOCK_SNOO_DEVICES + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snoo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +class MockedSnoo(Snoo): + """Mock the Snoo object.""" + + def __init__(self, email, password, clientsession) -> None: + """Set up a Mocked Snoo.""" + super().__init__(email, password, clientsession) + self.auth_error = None + + async def subscribe(self, device: SnooDevice, function): + """Mock the subscribe function.""" + return AsyncMock() + + async def send_command(self, command: str, device: SnooDevice, **kwargs): + """Mock the send command function.""" + return AsyncMock() + + async def authorize(self): + """Do normal auth flow unless error is patched.""" + if self.auth_error: + raise self.auth_error + return await super().authorize() + + def set_auth_error(self, error: Exception | None): + """Set an error for authentication.""" + self.auth_error = error + + async def auth_amazon(self): + """Mock the amazon auth.""" + return MOCK_AMAZON_AUTH + + async def auth_snoo(self, id_token): + """Mock the snoo auth.""" + return MOCK_SNOO_AUTH + + async def schedule_reauthorization(self, snoo_expiry: int): + """Mock scheduling reauth.""" + return AsyncMock() + + async def get_devices(self) -> list[SnooDevice]: + """Move getting devices.""" + return [SnooDevice.from_dict(dev) for dev in MOCK_SNOO_DEVICES] + + +@pytest.fixture(name="bypass_api") +def bypass_api() -> MockedSnoo: + """Bypass the Snoo api.""" + api = MockedSnoo("email", "password", AsyncMock()) + with ( + patch("homeassistant.components.snoo.Snoo", return_value=api), + patch("homeassistant.components.snoo.config_flow.Snoo", return_value=api), + ): + yield api diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py new file mode 100644 index 00000000000..c5d53780fa1 --- /dev/null +++ b/tests/components/snoo/const.py @@ -0,0 +1,34 @@ +"""Snoo constants for testing.""" + +MOCK_AMAZON_AUTH = { + # This is a JWT with random values. + "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2" + "LTQ3ODktOTBhYi1jZGVmMDEyMzQ1NjciLCJpc3MiOiJodHRwczovL2NvZ25pdG8taWRwLnVzLXdlc3Qt" + "Mi5hbWF6b25hd3MuY29tL3VzLXdlc3QtMl9FeGFtcGxlVXNlclBvb2xJZCIsImNsaWVudF9pZCI6ImFiY" + "2RlZmdoMTIzNDU2Nzg5MGFiY2RlZmdoMTIiLCJvcmlnaW5fanRpIjoiYjhkOWUwZjEtMmczaC00aTVqLT" + "ZrN2wtOG05bjBvMXAycTNyIiwiZXZlbnRfaWQiOiJmMGcxaDJpMy00ajVrLTZsN20tOG45by0wcDFxMnI" + "zczR0NXUiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6ImF3cy5jb2duaXRvLnNpZ25pbi51c2Vy" + "LmFkbWluIiwiYXV0aF90aW1lIjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwM" + "DAwMCwianRpIjoidjZ3N3g4eTktMHoxYS0yYjNjLTRkNWUtNmY3ZzhoOWkwajFrIiwidXNlcm5hbWUiOi" + "IxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifQ.zH5vy5itWot_5-rdJgYoygeKx696" + "Uge46zxXMhdn5RE", + "IdToken": "random_id", + "RefreshToken": "refresh_token", +} + +MOCK_SNOO_AUTH = {"expiresIn": 10800, "snoo": {"token": "random_snoo_token"}} + +MOCK_SNOO_DEVICES = [ + { + "serialNumber": "random_num", + "deviceType": 1, + "firmwareVersion": 1.0, + "babyIds": ["35235-211235-dfasdf-32523"], + "name": "Test Snoo", + "presence": {}, + "presenceIoT": {}, + "awsIoT": {}, + "lastSSID": {}, + "provisionedAt": "random_time", + } +] diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py new file mode 100644 index 00000000000..ffdfb22142d --- /dev/null +++ b/tests/components/snoo/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Happiest Baby Snoo config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException + +from homeassistant import config_entries +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import create_entry +from .conftest import MockedSnoo + + +async def test_config_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: MockedSnoo +) -> None: + """Test we create the entry successfully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "123e4567-e89b-12d3-a456-426614174000" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (InvalidSnooAuth, "invalid_auth"), + (SnooAuthException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_issues( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + bypass_api: MockedSnoo, + exception, + error_msg, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # Set Authorize to fail. + bypass_api.set_auth_error(exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + # Reset auth back to the original + bypass_api.set_auth_error(None) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error_msg} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_account_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api +) -> None: + """Ensure we abort if the config flow already exists.""" + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py new file mode 100644 index 00000000000..06f420b6518 --- /dev/null +++ b/tests/components/snoo/test_init.py @@ -0,0 +1,14 @@ +"""Test init for Snoo.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import async_init_integration +from .conftest import MockedSnoo + + +async def test_async_setup_entry(hass: HomeAssistant, bypass_api: MockedSnoo) -> None: + """Test a successful setup entry.""" + entry = await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 2 + assert entry.state == ConfigEntryState.LOADED From beec67a247fbdca4b730624a2b203b02a90d1919 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Feb 2025 13:52:31 +0100 Subject: [PATCH 1174/1435] Bump zwave-js-server-python to 0.60.1 (#139185) Bump zwave-js-server-python 0.60.1 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 011776f4556..3178bdf46ad 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 50c4ad93559..738f8d3d918 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3158,7 +3158,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1c713424b4..0c5dfa45469 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeversolar==0.3.2 zha==0.0.49 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 0b7a023d2e079dff5cdf04571fa01a24bcd13a31 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 24 Feb 2025 13:56:06 +0100 Subject: [PATCH 1175/1435] Fix description of `cycle` field in `input_select.select_previous` action (#139032) --- homeassistant/components/input_select/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index c46e3740b68..72fd50f7ec7 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -44,7 +44,7 @@ "fields": { "cycle": { "name": "[%key:component::input_select::services::select_next::fields::cycle::name%]", - "description": "[%key:component::input_select::services::select_next::fields::cycle::description%]" + "description": "If the option should cycle from the first to the last option on the list." } } }, From 37240e811bd2655f77365cc0612b0163ddd08919 Mon Sep 17 00:00:00 2001 From: Antonio Larrosa Date: Mon, 24 Feb 2025 13:57:21 +0100 Subject: [PATCH 1176/1435] Add melcloud standard horizontal vane modes (#136654) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/melcloud/climate.py | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 03bb4babf1c..9c2ee60b12c 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -152,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate): self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}" self._attr_device_info = self.api.device_info + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We can only check for vane_horizontal once we fetch the device data from the cloud + if self._device.vane_horizontal: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the optional state attributes with device specific additions.""" @@ -274,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate): """Return vertical vane position or mode.""" return self._device.vane_vertical + @property + def swing_horizontal_mode(self) -> str | None: + """Return horizontal vane position or mode.""" + return self._device.vane_horizontal + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set vertical vane position or mode.""" await self.async_set_vane_vertical(swing_mode) + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set horizontal vane position or mode.""" + await self.async_set_vane_horizontal(swing_horizontal_mode) + @property def swing_modes(self) -> list[str] | None: """Return a list of available vertical vane positions and modes.""" return self._device.vane_vertical_positions + @property + def swing_horizontal_modes(self) -> list[str] | None: + """Return a list of available horizontal vane positions and modes.""" + return self._device.vane_horizontal_positions + async def async_turn_on(self) -> None: """Turn the entity on.""" await self._device.set({"power": True}) From f98720e525b62c7e5efbf5569ef8208a56439760 Mon Sep 17 00:00:00 2001 From: laiho-vogels <144690720+laiho-vogels@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:59:34 +0100 Subject: [PATCH 1177/1435] Change code owner - MotionMount integration (#139187) --- CODEOWNERS | 4 ++-- homeassistant/components/motionmount/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3397948d7c8..b16c1e7e1f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -967,8 +967,8 @@ build.json @home-assistant/supervisor /tests/components/motionblinds_ble/ @LennP @jerrybboy /homeassistant/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy -/homeassistant/components/motionmount/ @RJPoelstra -/tests/components/motionmount/ @RJPoelstra +/homeassistant/components/motionmount/ @laiho-vogels +/tests/components/motionmount/ @laiho-vogels /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/msteams/ @peroyvind diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 2665836ffd4..337ce776b33 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -1,7 +1,7 @@ { "domain": "motionmount", "name": "Vogel's MotionMount", - "codeowners": ["@RJPoelstra"], + "codeowners": ["@laiho-vogels"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", From 5025e311299608800d4461a8cb7055165f14456b Mon Sep 17 00:00:00 2001 From: SteveDiks <126147459+SteveDiks@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:01:40 +0100 Subject: [PATCH 1178/1435] Bump Weheat to 2025.2.22 (#139186) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 1d60f66afba..a408303d062 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.1.15"] + "requirements": ["weheat==2025.2.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 738f8d3d918..1ce88e0f55d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3055,7 +3055,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.15 +weheat==2025.2.22 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c5dfa45469..c6588b06c41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2459,7 +2459,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.15 +weheat==2025.2.22 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 From 51a881f3b50ae8df3ed8f5ad21fbf57089e15a31 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 24 Feb 2025 06:09:43 -0800 Subject: [PATCH 1179/1435] Add ambient temperature and humidity status sensors to NUT (#124181) Co-authored-by: J. Nick Koston --- CODEOWNERS | 4 +- homeassistant/components/nut/diagnostics.py | 4 +- homeassistant/components/nut/icons.json | 6 + homeassistant/components/nut/manifest.json | 2 +- homeassistant/components/nut/sensor.py | 23 + homeassistant/components/nut/strings.json | 2 + tests/components/nut/conftest.py | 5 + .../nut/fixtures/EATON-EPDU-G3.json | 539 ++++++++++++++++++ tests/components/nut/test_init.py | 50 +- tests/components/nut/test_sensor.py | 71 ++- tests/components/nut/util.py | 27 + 11 files changed, 724 insertions(+), 9 deletions(-) create mode 100644 tests/components/nut/conftest.py create mode 100644 tests/components/nut/fixtures/EATON-EPDU-G3.json diff --git a/CODEOWNERS b/CODEOWNERS index b16c1e7e1f8..61b2eb5b557 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1051,8 +1051,8 @@ build.json @home-assistant/supervisor /tests/components/numato/ @clssn /homeassistant/components/number/ @home-assistant/core @Shulyaka /tests/components/number/ @home-assistant/core @Shulyaka -/homeassistant/components/nut/ @bdraco @ollo69 @pestevez -/tests/components/nut/ @bdraco @ollo69 @pestevez +/homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain +/tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain /homeassistant/components/nws/ @MatthewFlamm @kamiyo /tests/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nyt_games/ @joostlek diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 532e4ece76b..ec59fa65c22 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics( hass_device = device_registry.async_get_device( identifiers={(DOMAIN, hass_data.unique_id)} ) - if not hass_device: - return data + # Device is always created + assert hass_device is not None data["device"] = { **attr.asdict(hass_device), diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index e0f78d6400b..91df9d10553 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "ambient_humidity_status": { + "default": "mdi:information-outline" + }, + "ambient_temperature_status": { + "default": "mdi:information-outline" + }, "battery_alarm_threshold": { "default": "mdi:information-outline" }, diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index fb6c8561b25..1ee85a84caf 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -1,7 +1,7 @@ { "domain": "nut", "name": "Network UPS Tools (NUT)", - "codeowners": ["@bdraco", "@ollo69", "@pestevez"], + "codeowners": ["@bdraco", "@ollo69", "@pestevez", "@tdfountain"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nut", "integration_type": "device", diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 22e0496d0de..2f574ec4842 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -46,8 +46,17 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "serial": ATTR_SERIAL_NUMBER, } +AMBIENT_THRESHOLD_STATUS_OPTIONS = [ + "good", + "warning-low", + "critical-low", + "warning-high", + "critical-high", +] + _LOGGER = logging.getLogger(__name__) + SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", @@ -930,6 +939,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.humidity.status": SensorEntityDescription( + key="ambient.humidity.status", + translation_key="ambient_humidity_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", translation_key="ambient_temperature", @@ -938,6 +954,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.temperature.status": SensorEntityDescription( + key="ambient.temperature.status", + translation_key="ambient_temperature_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "watts": SensorEntityDescription( key="watts", translation_key="watts", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 83b8d340dc1..b9485a320fb 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -80,7 +80,9 @@ "entity": { "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, + "ambient_humidity_status": { "name": "Ambient humidity status" }, "ambient_temperature": { "name": "Ambient temperature" }, + "ambient_temperature_status": { "name": "Ambient temperature status" }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, diff --git a/tests/components/nut/conftest.py b/tests/components/nut/conftest.py new file mode 100644 index 00000000000..bcf1cb4a99f --- /dev/null +++ b/tests/components/nut/conftest.py @@ -0,0 +1,5 @@ +"""NUT session fixtures.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.nut.util") diff --git a/tests/components/nut/fixtures/EATON-EPDU-G3.json b/tests/components/nut/fixtures/EATON-EPDU-G3.json new file mode 100644 index 00000000000..cd6aeb4fd92 --- /dev/null +++ b/tests/components/nut/fixtures/EATON-EPDU-G3.json @@ -0,0 +1,539 @@ +{ + "ambient.contacts.1.status": "opened", + "ambient.contacts.2.status": "opened", + "ambient.count": "0", + "ambient.humidity": "29.90", + "ambient.humidity.high": "90", + "ambient.humidity.high.critical": "90", + "ambient.humidity.high.warning": "65", + "ambient.humidity.low": "10", + "ambient.humidity.low.critical": "10", + "ambient.humidity.low.warning": "20", + "ambient.humidity.status": "good", + "ambient.present": "yes", + "ambient.temperature": "28.9", + "ambient.temperature.high": "43.30", + "ambient.temperature.high.critical": "43.30", + "ambient.temperature.high.warning": "37.70", + "ambient.temperature.low": "5", + "ambient.temperature.low.critical": "5", + "ambient.temperature.low.warning": "10", + "ambient.temperature.status": "good", + "device.contact": "Contact Name", + "device.count": "1", + "device.description": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.location": "Device Location", + "device.macaddr": "00 00 00 FF FF FF ", + "device.mfr": "EATON", + "device.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.part": "EMA000-00", + "device.serial": "A000A00000", + "device.type": "pdu", + "driver.debug": "0", + "driver.flag.allow_killpower": "0", + "driver.name": "snmp-ups", + "driver.parameter.pollinterval": "2", + "driver.parameter.port": "eaton-pdu", + "driver.parameter.synchronous": "auto", + "driver.state": "dumping", + "driver.version": "2.8.2.882-882-g63d90ebcb", + "driver.version.data": "eaton_epdu MIB 0.69", + "driver.version.internal": "1.31", + "input.current": "4.30", + "input.current.high.critical": "16", + "input.current.high.warning": "12.80", + "input.current.low.warning": "0", + "input.current.nominal": "16", + "input.current.status": "good", + "input.feed.color": "0", + "input.feed.desc": "Feed A", + "input.frequency": "60", + "input.frequency.status": "good", + "input.L1.current": "4.30", + "input.L1.current.high.critical": "16", + "input.L1.current.high.warning": "12.80", + "input.L1.current.low.warning": "0", + "input.L1.current.nominal": "16", + "input.L1.current.status": "good", + "input.L1.load": "26", + "input.L1.power": "529", + "input.L1.realpower": "482", + "input.L1.voltage": "122.91", + "input.L1.voltage.high.critical": "140", + "input.L1.voltage.high.warning": "130", + "input.L1.voltage.low.critical": "90", + "input.L1.voltage.low.warning": "95", + "input.L1.voltage.status": "good", + "input.load": "26", + "input.phases": "1", + "input.power": "532", + "input.realpower": "482", + "input.realpower.nominal": "1920", + "input.voltage": "122.91", + "input.voltage.high.critical": "140", + "input.voltage.high.warning": "130", + "input.voltage.low.critical": "90", + "input.voltage.low.warning": "95", + "input.voltage.status": "good", + "outlet.1.current": "0", + "outlet.1.current.high.critical": "16", + "outlet.1.current.high.warning": "12.80", + "outlet.1.current.low.warning": "0", + "outlet.1.current.status": "good", + "outlet.1.delay.shutdown": "120", + "outlet.1.delay.start": "1", + "outlet.1.desc": "Outlet A1", + "outlet.1.groupid": "1", + "outlet.1.id": "1", + "outlet.1.name": "A1", + "outlet.1.power": "0", + "outlet.1.realpower": "0", + "outlet.1.status": "on", + "outlet.1.switchable": "yes", + "outlet.1.timer.shutdown": "-1", + "outlet.1.timer.start": "-1", + "outlet.1.type": "nema520", + "outlet.10.current": "0.26", + "outlet.10.current.high.critical": "16", + "outlet.10.current.high.warning": "12.80", + "outlet.10.current.low.warning": "0", + "outlet.10.current.status": "good", + "outlet.10.delay.shutdown": "120", + "outlet.10.delay.start": "10", + "outlet.10.desc": "Outlet A10", + "outlet.10.groupid": "1", + "outlet.10.id": "10", + "outlet.10.name": "A10", + "outlet.10.power": "32", + "outlet.10.realpower": "15", + "outlet.10.status": "on", + "outlet.10.switchable": "yes", + "outlet.10.timer.shutdown": "-1", + "outlet.10.timer.start": "-1", + "outlet.10.type": "nema520", + "outlet.11.current": "0.24", + "outlet.11.current.high.critical": "16", + "outlet.11.current.high.warning": "12.80", + "outlet.11.current.low.warning": "0", + "outlet.11.current.status": "good", + "outlet.11.delay.shutdown": "120", + "outlet.11.delay.start": "11", + "outlet.11.desc": "Outlet A11", + "outlet.11.groupid": "1", + "outlet.11.id": "11", + "outlet.11.name": "A11", + "outlet.11.power": "29", + "outlet.11.realpower": "22", + "outlet.11.status": "on", + "outlet.11.switchable": "yes", + "outlet.11.timer.shutdown": "-1", + "outlet.11.timer.start": "-1", + "outlet.11.type": "nema520", + "outlet.12.current": "0", + "outlet.12.current.high.critical": "16", + "outlet.12.current.high.warning": "12.80", + "outlet.12.current.low.warning": "0", + "outlet.12.current.status": "good", + "outlet.12.delay.shutdown": "120", + "outlet.12.delay.start": "12", + "outlet.12.desc": "Outlet A12", + "outlet.12.groupid": "1", + "outlet.12.id": "12", + "outlet.12.name": "A12", + "outlet.12.power": "0", + "outlet.12.realpower": "0", + "outlet.12.status": "on", + "outlet.12.switchable": "yes", + "outlet.12.timer.shutdown": "-1", + "outlet.12.timer.start": "-1", + "outlet.12.type": "nema520", + "outlet.13.current": "0.23", + "outlet.13.current.high.critical": "16", + "outlet.13.current.high.warning": "12.80", + "outlet.13.current.low.warning": "0", + "outlet.13.current.status": "good", + "outlet.13.delay.shutdown": "0", + "outlet.13.delay.start": "0", + "outlet.13.desc": "Outlet A13", + "outlet.13.groupid": "1", + "outlet.13.id": "0", + "outlet.13.name": "A13", + "outlet.13.power": "27", + "outlet.13.realpower": "9", + "outlet.13.status": "on", + "outlet.13.switchable": "yes", + "outlet.13.timer.shutdown": "-1", + "outlet.13.timer.start": "-1", + "outlet.13.type": "nema520", + "outlet.14.current": "0.10", + "outlet.14.current.high.critical": "16", + "outlet.14.current.high.warning": "12.80", + "outlet.14.current.low.warning": "0", + "outlet.14.current.status": "good", + "outlet.14.delay.shutdown": "120", + "outlet.14.delay.start": "14", + "outlet.14.desc": "Outlet A14", + "outlet.14.groupid": "1", + "outlet.14.id": "14", + "outlet.14.name": "A14", + "outlet.14.power": "12", + "outlet.14.realpower": "7", + "outlet.14.status": "on", + "outlet.14.switchable": "yes", + "outlet.14.timer.shutdown": "-1", + "outlet.14.timer.start": "-1", + "outlet.14.type": "nema520", + "outlet.15.current": "0.03", + "outlet.15.current.high.critical": "16", + "outlet.15.current.high.warning": "12.80", + "outlet.15.current.low.warning": "0", + "outlet.15.current.status": "good", + "outlet.15.delay.shutdown": "120", + "outlet.15.delay.start": "15", + "outlet.15.desc": "Outlet A15", + "outlet.15.groupid": "1", + "outlet.15.id": "15", + "outlet.15.name": "A15", + "outlet.15.power": "3", + "outlet.15.realpower": "1", + "outlet.15.status": "on", + "outlet.15.switchable": "yes", + "outlet.15.timer.shutdown": "-1", + "outlet.15.timer.start": "-1", + "outlet.15.type": "nema520", + "outlet.16.current": "0.04", + "outlet.16.current.high.critical": "16", + "outlet.16.current.high.warning": "12.80", + "outlet.16.current.low.warning": "0", + "outlet.16.current.status": "good", + "outlet.16.delay.shutdown": "120", + "outlet.16.delay.start": "16", + "outlet.16.desc": "Outlet A16", + "outlet.16.groupid": "1", + "outlet.16.id": "16", + "outlet.16.name": "A16", + "outlet.16.power": "4", + "outlet.16.realpower": "1", + "outlet.16.status": "on", + "outlet.16.switchable": "yes", + "outlet.16.timer.shutdown": "-1", + "outlet.16.timer.start": "-1", + "outlet.16.type": "nema520", + "outlet.17.current": "0.19", + "outlet.17.current.high.critical": "16", + "outlet.17.current.high.warning": "12.80", + "outlet.17.current.low.warning": "0", + "outlet.17.current.status": "good", + "outlet.17.delay.shutdown": "0", + "outlet.17.delay.start": "0", + "outlet.17.desc": "Outlet A17", + "outlet.17.groupid": "1", + "outlet.17.id": "0", + "outlet.17.name": "A17", + "outlet.17.power": "23", + "outlet.17.realpower": "5", + "outlet.17.status": "on", + "outlet.17.switchable": "yes", + "outlet.17.timer.shutdown": "-1", + "outlet.17.timer.start": "-1", + "outlet.17.type": "nema520", + "outlet.18.current": "0.35", + "outlet.18.current.high.critical": "16", + "outlet.18.current.high.warning": "12.80", + "outlet.18.current.low.warning": "0", + "outlet.18.current.status": "good", + "outlet.18.delay.shutdown": "0", + "outlet.18.delay.start": "0", + "outlet.18.desc": "Outlet A18", + "outlet.18.groupid": "1", + "outlet.18.id": "0", + "outlet.18.name": "A18", + "outlet.18.power": "42", + "outlet.18.realpower": "34", + "outlet.18.status": "on", + "outlet.18.switchable": "yes", + "outlet.18.timer.shutdown": "-1", + "outlet.18.timer.start": "-1", + "outlet.18.type": "nema520", + "outlet.19.current": "0.12", + "outlet.19.current.high.critical": "16", + "outlet.19.current.high.warning": "12.80", + "outlet.19.current.low.warning": "0", + "outlet.19.current.status": "good", + "outlet.19.delay.shutdown": "0", + "outlet.19.delay.start": "0", + "outlet.19.desc": "Outlet A19", + "outlet.19.groupid": "1", + "outlet.19.id": "0", + "outlet.19.name": "A19", + "outlet.19.power": "15", + "outlet.19.realpower": "6", + "outlet.19.status": "on", + "outlet.19.switchable": "yes", + "outlet.19.timer.shutdown": "-1", + "outlet.19.timer.start": "-1", + "outlet.19.type": "nema520", + "outlet.2.current": "0.39", + "outlet.2.current.high.critical": "16", + "outlet.2.current.high.warning": "12.80", + "outlet.2.current.low.warning": "0", + "outlet.2.current.status": "good", + "outlet.2.delay.shutdown": "120", + "outlet.2.delay.start": "2", + "outlet.2.desc": "Outlet A2", + "outlet.2.groupid": "1", + "outlet.2.id": "2", + "outlet.2.name": "A2", + "outlet.2.power": "47", + "outlet.2.realpower": "43", + "outlet.2.status": "on", + "outlet.2.switchable": "yes", + "outlet.2.timer.shutdown": "-1", + "outlet.2.timer.start": "-1", + "outlet.2.type": "nema520", + "outlet.20.current": "0", + "outlet.20.current.high.critical": "16", + "outlet.20.current.high.warning": "12.80", + "outlet.20.current.low.warning": "0", + "outlet.20.current.status": "good", + "outlet.20.delay.shutdown": "120", + "outlet.20.delay.start": "20", + "outlet.20.desc": "Outlet A20", + "outlet.20.groupid": "1", + "outlet.20.id": "20", + "outlet.20.name": "A20", + "outlet.20.power": "0", + "outlet.20.realpower": "0", + "outlet.20.status": "on", + "outlet.20.switchable": "yes", + "outlet.20.timer.shutdown": "-1", + "outlet.20.timer.start": "-1", + "outlet.20.type": "nema520", + "outlet.21.current": "0", + "outlet.21.current.high.critical": "16", + "outlet.21.current.high.warning": "12.80", + "outlet.21.current.low.warning": "0", + "outlet.21.current.status": "good", + "outlet.21.delay.shutdown": "120", + "outlet.21.delay.start": "21", + "outlet.21.desc": "Outlet A21", + "outlet.21.groupid": "1", + "outlet.21.id": "21", + "outlet.21.name": "A21", + "outlet.21.power": "0", + "outlet.21.realpower": "0", + "outlet.21.status": "on", + "outlet.21.switchable": "yes", + "outlet.21.timer.shutdown": "-1", + "outlet.21.timer.start": "-1", + "outlet.21.type": "nema520", + "outlet.22.current": "0", + "outlet.22.current.high.critical": "16", + "outlet.22.current.high.warning": "12.80", + "outlet.22.current.low.warning": "0", + "outlet.22.current.status": "good", + "outlet.22.delay.shutdown": "0", + "outlet.22.delay.start": "0", + "outlet.22.desc": "Outlet A22", + "outlet.22.groupid": "1", + "outlet.22.id": "0", + "outlet.22.name": "A22", + "outlet.22.power": "0", + "outlet.22.realpower": "0", + "outlet.22.status": "on", + "outlet.22.switchable": "yes", + "outlet.22.timer.shutdown": "-1", + "outlet.22.timer.start": "-1", + "outlet.22.type": "nema520", + "outlet.23.current": "0.34", + "outlet.23.current.high.critical": "16", + "outlet.23.current.high.warning": "12.80", + "outlet.23.current.low.warning": "0", + "outlet.23.current.status": "good", + "outlet.23.delay.shutdown": "120", + "outlet.23.delay.start": "23", + "outlet.23.desc": "Outlet A23", + "outlet.23.groupid": "1", + "outlet.23.id": "23", + "outlet.23.name": "A23", + "outlet.23.power": "41", + "outlet.23.realpower": "39", + "outlet.23.status": "on", + "outlet.23.switchable": "yes", + "outlet.23.timer.shutdown": "-1", + "outlet.23.timer.start": "-1", + "outlet.23.type": "nema520", + "outlet.24.current": "0.19", + "outlet.24.current.high.critical": "16", + "outlet.24.current.high.warning": "12.80", + "outlet.24.current.low.warning": "0", + "outlet.24.current.status": "good", + "outlet.24.delay.shutdown": "0", + "outlet.24.delay.start": "0", + "outlet.24.desc": "Outlet A24", + "outlet.24.groupid": "1", + "outlet.24.id": "0", + "outlet.24.name": "A24", + "outlet.24.power": "23", + "outlet.24.realpower": "11", + "outlet.24.status": "on", + "outlet.24.switchable": "yes", + "outlet.24.timer.shutdown": "-1", + "outlet.24.timer.start": "-1", + "outlet.24.type": "nema520", + "outlet.3.current": "0.46", + "outlet.3.current.high.critical": "16", + "outlet.3.current.high.warning": "12.80", + "outlet.3.current.low.warning": "0", + "outlet.3.current.status": "good", + "outlet.3.delay.shutdown": "120", + "outlet.3.delay.start": "3", + "outlet.3.desc": "Outlet A3", + "outlet.3.groupid": "1", + "outlet.3.id": "3", + "outlet.3.name": "A3", + "outlet.3.power": "56", + "outlet.3.realpower": "53", + "outlet.3.status": "on", + "outlet.3.switchable": "yes", + "outlet.3.timer.shutdown": "-1", + "outlet.3.timer.start": "-1", + "outlet.3.type": "nema520", + "outlet.4.current": "0.44", + "outlet.4.current.high.critical": "16", + "outlet.4.current.high.warning": "12.80", + "outlet.4.current.low.warning": "0", + "outlet.4.current.status": "good", + "outlet.4.delay.shutdown": "120", + "outlet.4.delay.start": "4", + "outlet.4.desc": "Outlet A4", + "outlet.4.groupid": "1", + "outlet.4.id": "4", + "outlet.4.name": "A4", + "outlet.4.power": "53", + "outlet.4.realpower": "48", + "outlet.4.status": "on", + "outlet.4.switchable": "yes", + "outlet.4.timer.shutdown": "-1", + "outlet.4.timer.start": "-1", + "outlet.4.type": "nema520", + "outlet.5.current": "0.43", + "outlet.5.current.high.critical": "16", + "outlet.5.current.high.warning": "12.80", + "outlet.5.current.low.warning": "0", + "outlet.5.current.status": "good", + "outlet.5.delay.shutdown": "120", + "outlet.5.delay.start": "5", + "outlet.5.desc": "Outlet A5", + "outlet.5.groupid": "1", + "outlet.5.id": "5", + "outlet.5.name": "A5", + "outlet.5.power": "52", + "outlet.5.realpower": "48", + "outlet.5.status": "on", + "outlet.5.switchable": "yes", + "outlet.5.timer.shutdown": "-1", + "outlet.5.timer.start": "-1", + "outlet.5.type": "nema520", + "outlet.6.current": "1.07", + "outlet.6.current.high.critical": "16", + "outlet.6.current.high.warning": "12.80", + "outlet.6.current.low.warning": "0", + "outlet.6.current.status": "good", + "outlet.6.delay.shutdown": "120", + "outlet.6.delay.start": "6", + "outlet.6.desc": "Outlet A6", + "outlet.6.groupid": "1", + "outlet.6.id": "6", + "outlet.6.name": "A6", + "outlet.6.power": "131", + "outlet.6.realpower": "118", + "outlet.6.status": "on", + "outlet.6.switchable": "yes", + "outlet.6.timer.shutdown": "-1", + "outlet.6.timer.start": "-1", + "outlet.6.type": "nema520", + "outlet.7.current": "0", + "outlet.7.current.high.critical": "16", + "outlet.7.current.high.warning": "12.80", + "outlet.7.current.low.warning": "0", + "outlet.7.current.status": "good", + "outlet.7.delay.shutdown": "120", + "outlet.7.delay.start": "7", + "outlet.7.desc": "Outlet A7", + "outlet.7.groupid": "1", + "outlet.7.id": "7", + "outlet.7.name": "A7", + "outlet.7.power": "0", + "outlet.7.realpower": "0", + "outlet.7.status": "on", + "outlet.7.switchable": "yes", + "outlet.7.timer.shutdown": "-1", + "outlet.7.timer.start": "-1", + "outlet.7.type": "nema520", + "outlet.8.current": "0", + "outlet.8.current.high.critical": "16", + "outlet.8.current.high.warning": "12.80", + "outlet.8.current.low.warning": "0", + "outlet.8.current.status": "good", + "outlet.8.delay.shutdown": "120", + "outlet.8.delay.start": "8", + "outlet.8.desc": "Outlet A8", + "outlet.8.groupid": "1", + "outlet.8.id": "8", + "outlet.8.name": "A8", + "outlet.8.power": "0", + "outlet.8.realpower": "0", + "outlet.8.status": "on", + "outlet.8.switchable": "yes", + "outlet.8.timer.shutdown": "-1", + "outlet.8.timer.start": "-1", + "outlet.8.type": "nema520", + "outlet.9.current": "0", + "outlet.9.current.high.critical": "16", + "outlet.9.current.high.warning": "12.80", + "outlet.9.current.low.warning": "0", + "outlet.9.current.status": "good", + "outlet.9.delay.shutdown": "120", + "outlet.9.delay.start": "9", + "outlet.9.desc": "Outlet A9", + "outlet.9.groupid": "1", + "outlet.9.id": "9", + "outlet.9.name": "A9", + "outlet.9.power": "0", + "outlet.9.realpower": "0", + "outlet.9.status": "on", + "outlet.9.switchable": "yes", + "outlet.9.timer.shutdown": "-1", + "outlet.9.timer.start": "-1", + "outlet.9.type": "nema520", + "outlet.count": "24", + "outlet.current": "43.05", + "outlet.desc": "All outlets", + "outlet.frequency": "60", + "outlet.group.1.color": "16051527", + "outlet.group.1.count": "24", + "outlet.group.1.desc": "Section A", + "outlet.group.1.id": "1", + "outlet.group.1.input": "1", + "outlet.group.1.name": "A", + "outlet.group.1.phase": "1", + "outlet.group.1.status": "on", + "outlet.group.1.type": "outlet-section", + "outlet.group.1.voltage": "122.83", + "outlet.group.1.voltage.high.critical": "140", + "outlet.group.1.voltage.high.warning": "130", + "outlet.group.1.voltage.low.critical": "90", + "outlet.group.1.voltage.low.warning": "95", + "outlet.group.1.voltage.status": "good", + "outlet.group.count": "1", + "outlet.id": "0", + "outlet.switchable": "yes", + "outlet.voltage": "122.91", + "ups.firmware": "05.01.0002", + "ups.mfr": "EATON", + "ups.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "ups.serial": "A000A00000", + "ups.status": "", + "ups.type": "pdu" +} diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index d5d85daa336..0585696cef2 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -1,12 +1,19 @@ """Test init of Nut integration.""" +from copy import deepcopy from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -147,3 +154,44 @@ async def test_device_location(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.suggested_area == mock_device_location + + +async def test_update_options(hass: HomeAssistant) -> None: + """Test update options triggers reload.""" + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ups.status": "OL"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "mock", + CONF_PASSWORD: "somepassword", + CONF_PORT: "mock", + CONF_USERNAME: "someuser", + }, + options={ + "device_options": { + "fake_option": "fake_option_value", + }, + }, + ) + 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 len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_options = deepcopy(dict(mock_config_entry.options)) + new_options["device_options"].clear() + hass.config_entries.async_update_entry(mock_config_entry, options=new_options) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index afe57631910..eb171c39011 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -5,17 +5,23 @@ from unittest.mock import patch import pytest from homeassistant.components.nut.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import _get_mock_nutclient, async_init_integration +from .util import ( + _get_mock_nutclient, + _test_sensor_and_attributes, + async_init_integration, +) from tests.common import MockConfigEntry @@ -32,7 +38,7 @@ from tests.common import MockConfigEntry "blazer_usb", ], ) -async def test_devices( +async def test_ups_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str ) -> None: """Test creation of device sensors.""" @@ -67,7 +73,7 @@ async def test_devices( ), ], ) -async def test_devices_with_unique_ids( +async def test_ups_devices_with_unique_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, unique_id: str ) -> None: """Test creation of device sensors with unique ids.""" @@ -92,6 +98,65 @@ async def test_devices_with_unique_ids( ) +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_pdu_devices_with_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test creation of device sensors with unique ids.""" + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}input.voltage", + device_id="sensor.ups1_input_voltage", + state_value="122.91", + expected_attributes={ + "device_class": SensorDeviceClass.VOLTAGE, + "state_class": SensorStateClass.MEASUREMENT, + "friendly_name": "Ups1 Input voltage", + "unit_of_measurement": UnitOfElectricPotential.VOLT, + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.humidity.status", + device_id="sensor.ups1_ambient_humidity_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient humidity status", + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.temperature.status", + device_id="sensor.ups1_ambient_temperature_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient temperature status", + }, + ) + + async def test_state_sensors(hass: HomeAssistant) -> None: """Test creation of status display sensors.""" entry = MockConfigEntry( diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index b6c9cffd390..bd82ffdd6b4 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, load_fixture @@ -79,3 +80,29 @@ async def async_init_integration( await hass.async_block_till_done() return entry + + +async def _test_sensor_and_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id: str, + device_id: str, + state_value: str, + expected_attributes: dict, +) -> None: + """Test creation of device sensors with unique ids.""" + + await async_init_integration(hass, model) + entry = entity_registry.async_get(device_id) + assert entry + assert entry.unique_id == unique_id + + state = hass.states.get(device_id) + assert state.state == state_value + + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == attr for key, attr in expected_attributes.items() + ) From 377da5f9547fe2a5c825e7fd28efdbe5a396e993 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 24 Feb 2025 17:11:07 +0200 Subject: [PATCH 1180/1435] Update LG webOS TV diagnostics to use tv_info and tv_state dictionaries (#139189) --- .../components/webostv/diagnostics.py | 11 +- .../webostv/snapshots/test_diagnostics.ambr | 101 +++++++++++------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 393a6a066ff..e4ea38064a8 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -32,15 +32,8 @@ async def async_get_config_entry_diagnostics( client_data = { "is_registered": client.is_registered(), "is_connected": client.is_connected(), - "current_app_id": client.tv_state.current_app_id, - "current_channel": client.tv_state.current_channel, - "apps": client.tv_state.apps, - "inputs": client.tv_state.inputs, - "system_info": client.tv_info.system, - "software_info": client.tv_info.software, - "hello_info": client.tv_info.hello, - "sound_output": client.tv_state.sound_output, - "is_on": client.tv_state.is_on, + "tv_info": client.tv_info.__dict__, + "tv_state": client.tv_state.__dict__, } return async_redact_data( diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr index 030554b963a..2febee15deb 100644 --- a/tests/components/webostv/snapshots/test_diagnostics.ambr +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -2,46 +2,73 @@ # name: test_diagnostics dict({ 'client': dict({ - 'apps': dict({ - 'com.webos.app.livetv': dict({ - 'icon': '**REDACTED**', - 'id': 'com.webos.app.livetv', - 'largeIcon': '**REDACTED**', - 'title': 'Live TV', - }), - }), - 'current_app_id': 'com.webos.app.livetv', - 'current_channel': dict({ - 'channelId': 'ch1id', - 'channelName': 'Channel 1', - 'channelNumber': '1', - }), - 'hello_info': dict({ - 'deviceUUID': '**REDACTED**', - }), - 'inputs': dict({ - 'in1': dict({ - 'appId': 'app0', - 'id': 'in1', - 'label': 'Input01', - }), - 'in2': dict({ - 'appId': 'app1', - 'id': 'in2', - 'label': 'Input02', - }), - }), 'is_connected': True, - 'is_on': True, 'is_registered': True, - 'software_info': dict({ - 'major_ver': 'major', - 'minor_ver': 'minor', + 'tv_info': dict({ + 'hello': dict({ + 'deviceUUID': '**REDACTED**', + }), + 'software': dict({ + 'major_ver': 'major', + 'minor_ver': 'minor', + }), + 'system': dict({ + 'modelName': 'MODEL', + 'serialNumber': '1234567890', + }), }), - 'sound_output': 'speaker', - 'system_info': dict({ - 'modelName': 'MODEL', - 'serialNumber': '1234567890', + 'tv_state': dict({ + 'apps': dict({ + 'com.webos.app.livetv': dict({ + 'icon': '**REDACTED**', + 'id': 'com.webos.app.livetv', + 'largeIcon': '**REDACTED**', + 'title': 'Live TV', + }), + }), + 'channel_info': None, + 'channels': list([ + dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + dict({ + 'channelId': 'ch2id', + 'channelName': 'Channel Name 2', + 'channelNumber': '20', + }), + ]), + 'current_app_id': 'com.webos.app.livetv', + 'current_channel': dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + 'inputs': dict({ + 'in1': dict({ + 'appId': 'app0', + 'id': 'in1', + 'label': 'Input01', + }), + 'in2': dict({ + 'appId': 'app1', + 'id': 'in2', + 'label': 'Input02', + }), + }), + 'is_on': True, + 'is_screen_on': False, + 'media_state': list([ + dict({ + 'playState': '', + }), + ]), + 'muted': False, + 'power_state': dict({ + }), + 'sound_output': 'speaker', + 'volume': 37, }), }), 'entry': dict({ From 351e594fe4cb6ec1b9f597e89c1b901910414a2a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 17:14:47 +0100 Subject: [PATCH 1181/1435] Add flag to backup store to track backup wizard completion (#138368) * Add flag to backup store to track backup wizard completion * Add comment * Update hassio tests * Update tests --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/config.py | 8 + homeassistant/components/backup/store.py | 7 +- homeassistant/components/backup/websocket.py | 1 + .../backup/snapshots/test_store.ambr | 212 ++++++++++- .../backup/snapshots/test_websocket.ambr | 345 +++++++++++++++++- tests/components/backup/test_store.py | 75 ++++ tests/components/backup/test_websocket.py | 26 ++ .../hassio/snapshots/test_backup.ambr | 3 + tests/components/hassio/test_backup.py | 2 + 9 files changed, 658 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index f34c1b8887d..65f9f4789a6 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -39,6 +39,7 @@ class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" agents: dict[str, StoredAgentConfig] + automatic_backups_configured: bool create_backup: StoredCreateBackupConfig last_attempted_automatic_backup: str | None last_completed_automatic_backup: str | None @@ -51,6 +52,7 @@ class BackupConfigData: """Represent loaded backup config data.""" agents: dict[str, AgentConfig] + automatic_backups_configured: bool # only used by frontend create_backup: CreateBackupConfig last_attempted_automatic_backup: datetime | None = None last_completed_automatic_backup: datetime | None = None @@ -88,6 +90,7 @@ class BackupConfigData: agent_id: AgentConfig(protected=agent_data["protected"]) for agent_id, agent_data in data["agents"].items() }, + automatic_backups_configured=data["automatic_backups_configured"], create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], include_addons=data["create_backup"]["include_addons"], @@ -127,6 +130,7 @@ class BackupConfigData: agents={ agent_id: agent.to_dict() for agent_id, agent in self.agents.items() }, + automatic_backups_configured=self.automatic_backups_configured, create_backup=self.create_backup.to_dict(), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, @@ -142,6 +146,7 @@ class BackupConfig: """Initialize backup config.""" self.data = BackupConfigData( agents={}, + automatic_backups_configured=False, create_backup=CreateBackupConfig(), retention=RetentionConfig(), schedule=BackupSchedule(), @@ -159,6 +164,7 @@ class BackupConfig: self, *, agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, + automatic_backups_configured: bool | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, @@ -172,6 +178,8 @@ class BackupConfig: self.data.agents[agent_id] = replace( self.data.agents[agent_id], **agent_config ) + if automatic_backups_configured is not UNDEFINED: + self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) if retention is not UNDEFINED: diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 8287080b5a2..883447853e6 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 = 4 +STORAGE_VERSION_MINOR = 5 class StoredBackupData(TypedDict): @@ -67,6 +67,11 @@ class _BackupStore(Store[StoredBackupData]): data["config"]["retention"]["copies"] = None if data["config"]["retention"]["days"] == 0: data["config"]["retention"]["days"] = None + if old_minor_version < 5: + # Version 1.5 adds automatic_backups_configured + data["config"]["automatic_backups_configured"] = ( + data["config"]["create_backup"]["password"] is not None + ) # Note: We allow reading data with major version 2. # Reject if major version is higher than 2. diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index b36343c7634..5084f904ec6 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -352,6 +352,7 @@ async def handle_config_info( { vol.Required("type"): "backup/config/update", vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), + vol.Optional("automatic_backups_configured"): bool, vol.Optional("create_backup"): vol.Schema( { vol.Optional("agent_ids"): vol.All([str], vol.Unique()), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 04f88b84a97..41778322825 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -13,6 +13,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -39,7 +40,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -57,6 +58,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -84,7 +86,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -102,6 +104,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -128,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -146,6 +149,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -173,7 +177,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -194,6 +198,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -220,7 +225,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -241,6 +246,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -268,7 +274,201 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 742fec4c3f3..c100a87e8cc 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -258,6 +258,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -295,6 +296,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -344,6 +346,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -382,6 +385,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -420,6 +424,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -459,6 +464,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -497,6 +503,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -543,6 +550,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -583,6 +591,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'hassio.local', @@ -623,6 +632,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'hassio.local', @@ -662,6 +672,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -699,6 +710,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -744,6 +756,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -782,6 +795,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -820,6 +834,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -859,6 +874,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -897,6 +913,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -943,6 +960,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -983,6 +1001,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'backup.local', @@ -1022,6 +1041,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'backup.local', @@ -1061,6 +1081,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1098,6 +1119,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1137,6 +1159,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1164,7 +1187,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1175,6 +1198,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1212,6 +1236,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1251,6 +1276,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1278,7 +1304,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1289,6 +1315,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1326,6 +1353,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1365,6 +1393,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1392,7 +1421,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1403,6 +1432,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1446,6 +1476,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1490,6 +1521,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1516,7 +1548,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1527,6 +1559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1570,6 +1603,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1613,6 +1647,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1657,6 +1692,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1683,7 +1719,237 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands14] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands14].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands14].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands15] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands15].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands15].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, 'version': 1, }) # --- @@ -1694,6 +1960,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1731,6 +1998,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1770,6 +2038,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1797,7 +2066,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1808,6 +2077,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1845,6 +2115,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1885,6 +2156,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1913,7 +2185,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1924,6 +2196,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1961,6 +2234,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2000,6 +2274,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2027,7 +2302,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2038,6 +2313,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2075,6 +2351,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2116,6 +2393,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2145,7 +2423,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2156,6 +2434,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2193,6 +2472,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2236,6 +2516,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2267,7 +2548,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2278,6 +2559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2315,6 +2597,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2354,6 +2637,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2381,7 +2665,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2392,6 +2676,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2429,6 +2714,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2468,6 +2754,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2495,7 +2782,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2506,6 +2793,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2543,6 +2831,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2582,6 +2871,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2609,7 +2899,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2620,6 +2910,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2657,6 +2948,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2696,6 +2988,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2723,7 +3016,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2734,6 +3027,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2771,6 +3065,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2808,6 +3103,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2845,6 +3141,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2882,6 +3179,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2919,6 +3217,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2956,6 +3255,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2993,6 +3293,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3030,6 +3331,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3067,6 +3369,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3104,6 +3407,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3141,6 +3445,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3178,6 +3483,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3215,6 +3521,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3252,6 +3559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3289,6 +3597,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3326,6 +3635,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3363,6 +3673,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3400,6 +3711,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3437,6 +3749,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3474,6 +3787,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3511,6 +3825,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3548,6 +3863,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3585,6 +3901,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index eff53bda777..0d29bb2006a 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -99,6 +99,7 @@ def mock_delay_save() -> Generator[None]: ], "config": { "agents": {"test.remote": {"protected": True}}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": [], "include_addons": None, @@ -125,6 +126,80 @@ def mock_delay_save() -> Generator[None]: "key": DOMAIN, "version": 2, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 6d5adb32c01..6605674a679 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -55,6 +55,7 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": [], "include_addons": None, @@ -907,6 +908,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -938,6 +940,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -969,6 +972,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1000,6 +1004,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1031,6 +1036,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1062,6 +1068,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1096,6 +1103,7 @@ async def test_agents_info( "test-agent1": {"protected": True}, "test-agent2": {"protected": False}, }, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1127,6 +1135,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["hassio.local", "hassio.share", "test-agent"], "include_addons": None, @@ -1158,6 +1167,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["backup.local", "test-agent"], "include_addons": None, @@ -1343,6 +1353,18 @@ async def test_config_load_config_info( }, }, ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": False, + } + ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": True, + } + ], ], ) @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) @@ -1774,6 +1796,7 @@ async def test_config_schedule_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test.test-agent"], "include_addons": [], @@ -2436,6 +2459,7 @@ async def test_config_retention_copies_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2714,6 +2738,7 @@ async def test_config_retention_copies_logic_manual_backup( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -3161,6 +3186,7 @@ async def test_config_retention_days_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], diff --git a/tests/components/hassio/snapshots/test_backup.ambr b/tests/components/hassio/snapshots/test_backup.ambr index a2f33bf9624..725239ee126 100644 --- a/tests/components/hassio/snapshots/test_backup.ambr +++ b/tests/components/hassio/snapshots/test_backup.ambr @@ -6,6 +6,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -43,6 +44,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent1', @@ -89,6 +91,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent1', diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6a66d249dd1..c7f400cef5c 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -2480,6 +2480,7 @@ async def test_restore_progress_after_restart_unknown_job( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent1", "hassio.local", "test-agent2"], "include_addons": ["addon1", "addon2"], @@ -2511,6 +2512,7 @@ async def test_restore_progress_after_restart_unknown_job( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent1", "backup.local", "test-agent2"], "include_addons": ["addon1", "addon2"], From 461039f06a8eddf83203b95200728db737be95ab Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:23:14 +0100 Subject: [PATCH 1182/1435] Add translations for exceptions and data descriptions to pyLoad integration (#138896) --- .../components/pyload/coordinator.py | 8 +++++-- homeassistant/components/pyload/strings.json | 22 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 937d8d71291..c57dfa7720d 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -78,10 +78,14 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): return self.data except CannotConnect as e: raise UpdateFailed( - "Unable to connect and retrieve data from pyLoad API" + translation_domain=DOMAIN, + translation_key="setup_request_exception", ) from e except ParserError as e: - raise UpdateFailed("Unable to parse data from pyLoad API") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 0fd9b4befcf..ed15a438c28 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -12,7 +12,11 @@ }, "data_description": { "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "username": "The username used to access the pyLoad instance.", + "password": "The password associated with the pyLoad account.", + "port": "pyLoad uses port 8000 by default.", + "ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.", + "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } }, "reconfigure": { @@ -25,8 +29,12 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "host": "[%key:component::pyload::config::step::user::data_description::host%]", + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]", + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]", + "port": "[%key:component::pyload::config::step::user::data_description::port%]", + "ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]" } }, "reauth_confirm": { @@ -34,6 +42,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" } } }, @@ -91,10 +103,10 @@ }, "exceptions": { "setup_request_exception": { - "message": "Unable to connect and retrieve data from pyLoad API, try again later" + "message": "Unable to connect and retrieve data from pyLoad API" }, "setup_parse_exception": { - "message": "Unable to parse data from pyLoad API, try again later" + "message": "Unable to parse data from pyLoad API" }, "setup_authentication_exception": { "message": "Authentication failed for {username}, verify your login credentials" From 2e5f56b70d144b2d19a2e757dbb39cce25eb9216 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:36:20 +0100 Subject: [PATCH 1183/1435] Refactor to-do list order and reordering in Habitica (#138566) --- homeassistant/components/habitica/todo.py | 54 +++++++++++-------- .../fixtures/reorder_dailies_response.json | 15 ++++++ .../fixtures/reorder_todos_response.json | 12 +++++ tests/components/habitica/test_todo.py | 31 +++++++++-- 4 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 tests/components/habitica/fixtures/reorder_dailies_response.json create mode 100644 tests/components/habitica/fixtures/reorder_todos_response.json diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index 29b98e90b04..71ba8e60e06 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -117,20 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): """Move an item in the To-do list.""" if TYPE_CHECKING: assert self.todo_items + tasks_order = ( + self.coordinator.data.user.tasksOrder.todos + if self.entity_description.key is HabiticaTodoList.TODOS + else self.coordinator.data.user.tasksOrder.dailys + ) if previous_uid: - pos = self.todo_items.index( - next(item for item in self.todo_items if item.uid == previous_uid) - ) - if pos < self.todo_items.index( - next(item for item in self.todo_items if item.uid == uid) - ): + pos = tasks_order.index(UUID(previous_uid)) + if pos < tasks_order.index(UUID(uid)): pos += 1 + else: pos = 0 try: - await self.coordinator.habitica.reorder_task(UUID(uid), pos) + tasks_order[:] = ( + await self.coordinator.habitica.reorder_task(UUID(uid), pos) + ).data except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -144,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): translation_key=f"move_{self.entity_description.key}_item_failed", translation_placeholders={"pos": str(pos)}, ) from e - else: - # move tasks in the coordinator until we have fresh data - tasks = self.coordinator.data.tasks - new_pos = ( - tasks.index( - next(task for task in tasks if task.id == UUID(previous_uid)) - ) - + 1 - if previous_uid - else 0 - ) - old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid))) - tasks.insert(new_pos, tasks.pop(old_pos)) - await self.coordinator.async_request_refresh() async def async_update_todo_item(self, item: TodoItem) -> None: """Update a Habitica todo.""" @@ -271,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): def todo_items(self) -> list[TodoItem]: """Return the todo items.""" - return [ + tasks = [ *( TodoItem( uid=str(task.id), @@ -288,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): if task.Type is TaskType.TODO ), ] + return sorted( + tasks, + key=lambda task: ( + float("inf") + if (uid := UUID(task.uid)) + not in (tasks_order := self.coordinator.data.user.tasksOrder.todos) + else tasks_order.index(uid) + ), + ) async def async_create_todo_item(self, item: TodoItem) -> None: """Create a Habitica todo.""" @@ -348,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): if TYPE_CHECKING: assert self.coordinator.data.user.lastCron - return [ + tasks = [ *( TodoItem( uid=str(task.id), @@ -365,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): if task.Type is TaskType.DAILY ) ] + return sorted( + tasks, + key=lambda task: ( + float("inf") + if (uid := UUID(task.uid)) + not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys) + else tasks_order.index(uid) + ), + ) diff --git a/tests/components/habitica/fixtures/reorder_dailies_response.json b/tests/components/habitica/fixtures/reorder_dailies_response.json new file mode 100644 index 00000000000..3ad38ae9c2f --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_dailies_response.json @@ -0,0 +1,15 @@ +{ + "success": true, + "data": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/reorder_todos_response.json b/tests/components/habitica/fixtures/reorder_todos_response.json new file mode 100644 index 00000000000..ba8118aa1da --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_todos_response.json @@ -0,0 +1,12 @@ +{ + "success": true, + "data": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 01c033fcf95..3457af78403 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -6,7 +6,13 @@ from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID -from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType +from habiticalib import ( + Direction, + HabiticaTaskOrderResponse, + HabiticaTasksResponse, + Task, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -601,19 +607,23 @@ async def test_delete_completed_todo_items_exception( @pytest.mark.parametrize( - ("entity_id", "uid", "second_pos", "third_pos"), + ("entity_id", "uid", "second_pos", "third_pos", "fixture", "task_type"), [ ( "todo.test_user_to_do_s", "1aa3137e-ef72-4d1f-91ee-41933602f438", "88de7cd9-af2b-49ce-9afd-bf941d87336b", "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "reorder_todos_response.json", + "todos", ), ( "todo.test_user_dailies", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", - "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", - "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "reorder_dailies_response.json", + "dailys", ), ], ids=["todo", "daily"], @@ -627,9 +637,14 @@ async def test_move_todo_item( uid: str, second_pos: str, third_pos: str, + fixture: str, + task_type: str, ) -> None: """Test move todo items.""" - + reorder_response = HabiticaTaskOrderResponse.from_json( + load_fixture(fixture, DOMAIN) + ) + habitica.reorder_task.return_value = reorder_response config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -650,6 +665,7 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1) + habitica.reorder_task.reset_mock() # move down to third position @@ -665,6 +681,7 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 2) + habitica.reorder_task.reset_mock() # move to top position @@ -679,6 +696,10 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) + assert ( + getattr(config_entry.runtime_data.data.user.tasksOrder, task_type) + == reorder_response.data + ) @pytest.mark.parametrize( From ec3f5561dc79331a4acbef20f8a858480a0b587e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 24 Feb 2025 18:00:48 +0100 Subject: [PATCH 1184/1435] Add WebDAV backup agent (#137721) * Add WebDAV backup agent * Process code review * Increase timeout for large uploads * Make metadata file based * Update IQS * Grammar * Move to aiowebdav2 * Update helper text * Add decorator to handle backup errors * Bump version * Missed one * Add unauth handling * Apply suggestions from code review Co-authored-by: Josef Zweck * Update homeassistant/components/webdav/__init__.py * Update homeassistant/components/webdav/config_flow.py * Remove timeout Co-authored-by: Josef Zweck * remove unique_id * Add tests * Add missing tests * Bump version * Remove dropbox * Process code review * Bump version to relax pinned dependencies * Process code review * Add translatable exceptions * Process code review * Process code review --------- Co-authored-by: Josef Zweck --- CODEOWNERS | 2 + homeassistant/components/webdav/__init__.py | 70 ++++ homeassistant/components/webdav/backup.py | 273 +++++++++++++++ .../components/webdav/config_flow.py | 90 +++++ homeassistant/components/webdav/const.py | 13 + homeassistant/components/webdav/helpers.py | 38 +++ homeassistant/components/webdav/manifest.json | 12 + .../components/webdav/quality_scale.yaml | 145 ++++++++ homeassistant/components/webdav/strings.json | 41 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/webdav/__init__.py | 1 + tests/components/webdav/conftest.py | 80 +++++ tests/components/webdav/const.py | 52 +++ tests/components/webdav/test_backup.py | 323 ++++++++++++++++++ tests/components/webdav/test_config_flow.py | 149 ++++++++ 18 files changed, 1302 insertions(+) create mode 100644 homeassistant/components/webdav/__init__.py create mode 100644 homeassistant/components/webdav/backup.py create mode 100644 homeassistant/components/webdav/config_flow.py create mode 100644 homeassistant/components/webdav/const.py create mode 100644 homeassistant/components/webdav/helpers.py create mode 100644 homeassistant/components/webdav/manifest.json create mode 100644 homeassistant/components/webdav/quality_scale.yaml create mode 100644 homeassistant/components/webdav/strings.json create mode 100644 tests/components/webdav/__init__.py create mode 100644 tests/components/webdav/conftest.py create mode 100644 tests/components/webdav/const.py create mode 100644 tests/components/webdav/test_backup.py create mode 100644 tests/components/webdav/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 61b2eb5b557..bb8545c46b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1695,6 +1695,8 @@ build.json @home-assistant/supervisor /tests/components/weatherflow_cloud/ @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner +/homeassistant/components/webdav/ @jpbede +/tests/components/webdav/ @jpbede /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webmin/ @autinerd diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py new file mode 100644 index 00000000000..952a68d829f --- /dev/null +++ b/homeassistant/components/webdav/__init__.py @@ -0,0 +1,70 @@ +"""The WebDAV integration.""" + +from __future__ import annotations + +import logging + +from aiowebdav2.client import Client +from aiowebdav2.exceptions import UnauthorizedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .helpers import async_create_client, async_ensure_path_exists + +type WebDavConfigEntry = ConfigEntry[Client] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Set up WebDAV from a config entry.""" + client = async_create_client( + hass=hass, + url=entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + verify_ssl=entry.data.get(CONF_VERIFY_SSL, True), + ) + + try: + result = await client.check() + except UnauthorizedError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_username_password", + ) from err + + # Check if we can connect to the WebDAV server + # and access the root directory + if not result: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) + + # Ensure the backup directory exists + if not await async_ensure_path_exists( + client, entry.data.get(CONF_BACKUP_PATH, "/") + ): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_access_or_create_backup_path", + ) + + entry.runtime_data = client + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Unload a WebDAV config entry.""" + return True diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py new file mode 100644 index 00000000000..2c19ca450e3 --- /dev/null +++ b/homeassistant/components/webdav/backup.py @@ -0,0 +1,273 @@ +"""Support for WebDAV backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import logging +from typing import Any, Concatenate + +from aiohttp import ClientTimeout +from aiowebdav2 import Property, PropertyRequest +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +from propcache.api import cached_property + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import json_loads_object + +from . import WebDavConfigEntry +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +METADATA_VERSION = "1" +BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[WebDavConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return [WebDavBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper(self: WebDavBackupAgent, *args: P.args, **kwargs: P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except UnauthorizedError as err: + raise BackupAgentError("Authentication error") from err + except WebDavError as err: + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError( + f"Backup operation failed: {err}", + ) from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +class WebDavBackupAgent(BackupAgent): + """Backup agent interface.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: WebDavConfigEntry) -> None: + """Initialize the WebDAV backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @cached_property + def _backup_path(self) -> str: + """Return the path to the backup.""" + return self._entry.data.get(CONF_BACKUP_PATH, "") + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + raise BackupNotFound("Backup not found") + + return await self._client.download_iter( + f"{self._backup_path}/{suggested_filename(backup)}", + timeout=BACKUP_TIMEOUT, + ) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + (filename_tar, filename_meta) = suggested_filenames(backup) + + await self._client.upload_iter( + await open_stream(), + f"{self._backup_path}/{filename_tar}", + timeout=BACKUP_TIMEOUT, + ) + + _LOGGER.debug( + "Uploaded backup to %s", + f"{self._backup_path}/{filename_tar}", + ) + + await self._client.upload_iter( + json_dumps(backup.as_dict()), + f"{self._backup_path}/{filename_meta}", + ) + + await self._client.set_property_batch( + f"{self._backup_path}/{filename_meta}", + [ + Property( + namespace="homeassistant", + name="backup_id", + value=backup.backup_id, + ), + Property( + namespace="homeassistant", + name="metadata_version", + value=METADATA_VERSION, + ), + ], + ) + + _LOGGER.debug( + "Uploaded metadata file for %s", + f"{self._backup_path}/{filename_meta}", + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + return + + (filename_tar, filename_meta) = suggested_filenames(backup) + backup_path = f"{self._backup_path}/{filename_tar}" + + await self._client.clean(backup_path) + await self._client.clean(f"{self._backup_path}/{filename_meta}") + + _LOGGER.debug( + "Deleted backup at %s", + backup_path, + ) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + metadata_files = await self._list_metadata_files() + return [ + await self._download_metadata(metadata_file) + for metadata_file in metadata_files + ] + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _list_metadata_files(self) -> list[str]: + """List metadata files.""" + files = await self._client.list_with_infos(self._backup_path) + return [ + file["path"] + for file in files + if file["path"].endswith(".json") + and await self._is_current_metadata_version(file["path"]) + ] + + async def _is_current_metadata_version(self, path: str) -> bool: + """Check if is current metadata version.""" + metadata_version = await self._client.get_property( + path, + PropertyRequest( + namespace="homeassistant", + name="metadata_version", + ), + ) + return metadata_version.value == METADATA_VERSION if metadata_version else False + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + for metadata_file in metadata_files: + remote_backup_id = await self._client.get_property( + metadata_file, + PropertyRequest( + namespace="homeassistant", + name="backup_id", + ), + ) + if remote_backup_id and remote_backup_id.value == backup_id: + return await self._download_metadata(metadata_file) + + return None + + async def _download_metadata(self, path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py new file mode 100644 index 00000000000..f75544d25ad --- /dev/null +++ b/homeassistant/components/webdav/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for the WebDAV integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiowebdav2.exceptions import UnauthorizedError +import voluptuous as vol +import yarl + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_BACKUP_PATH, DOMAIN +from .helpers import async_create_client + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + ) + ), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ) + ), + vol.Optional(CONF_BACKUP_PATH, default="/"): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for WebDAV.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = async_create_client( + hass=self.hass, + url=user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + verify_ssl=user_input.get(CONF_VERIFY_SSL, True), + ) + + # Check if we can connect to the WebDAV server + # .check() already does the most of the error handling and will return True + # if we can access the root directory + try: + result = await client.check() + except UnauthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + if result: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + parsed_url = yarl.URL(user_input[CONF_URL]) + return self.async_create_entry( + title=f"{user_input[CONF_USERNAME]}@{parsed_url.host}", + data=user_input, + ) + + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/webdav/const.py b/homeassistant/components/webdav/const.py new file mode 100644 index 00000000000..faf8ce77ca5 --- /dev/null +++ b/homeassistant/components/webdav/const.py @@ -0,0 +1,13 @@ +"""Constants for the WebDAV integration.""" + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "webdav" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + +CONF_BACKUP_PATH = "backup_path" diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py new file mode 100644 index 00000000000..9f91ed3bdb3 --- /dev/null +++ b/homeassistant/components/webdav/helpers.py @@ -0,0 +1,38 @@ +"""Helper functions for the WebDAV component.""" + +from aiowebdav2.client import Client, ClientOptions + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@callback +def async_create_client( + *, + hass: HomeAssistant, + url: str, + username: str, + password: str, + verify_ssl: bool = False, +) -> Client: + """Create a WebDAV client.""" + return Client( + url=url, + username=username, + password=password, + options=ClientOptions( + verify_ssl=verify_ssl, + session=async_get_clientsession(hass), + ), + ) + + +async def async_ensure_path_exists(client: Client, path: str) -> bool: + """Ensure that a path exists recursively on the WebDAV server.""" + parts = path.strip("/").split("/") + for i in range(1, len(parts) + 1): + sub_path = "/".join(parts[:i]) + if not await client.check(sub_path) and not await client.mkdir(sub_path): + return False + + return True diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json new file mode 100644 index 00000000000..a1ac779afc8 --- /dev/null +++ b/homeassistant/components/webdav/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "webdav", + "name": "WebDAV", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/webdav", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aiowebdav2"], + "quality_scale": "bronze", + "requirements": ["aiowebdav2==0.2.2"] +} diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml new file mode 100644 index 00000000000..560626fda7e --- /dev/null +++ b/homeassistant/components/webdav/quality_scale.yaml @@ -0,0 +1,145 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: + status: done + comment: | + No known limitations. + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: + status: exempt + comment: | + No issues known to troubleshoot. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: todo + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json new file mode 100644 index 00000000000..57117cdd9de --- /dev/null +++ b/homeassistant/components/webdav/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "backup_path": "Backup path", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "The URL of the WebDAV server. Check with your provider for the correct URL.", + "username": "The username for the WebDAV server.", + "password": "The password for the WebDAV server.", + "backup_path": "Define the path where the backups should be located (will be created automatically if it does not exist).", + "verify_ssl": "Whether to verify the SSL certificate of the server. If you are using a self-signed certificate, do not select this option." + } + } + }, + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "exceptions": { + "invalid_username_password": { + "message": "Invalid username or password" + }, + "cannot_connect": { + "message": "Cannot connect to WebDAV server" + }, + "cannot_access_or_create_backup_path": { + "message": "Cannot access or create backup path. Please check the path and permissions." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c92235aae47..de581c65297 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -692,6 +692,7 @@ FLOWS = { "weatherflow", "weatherflow_cloud", "weatherkit", + "webdav", "webmin", "webostv", "weheat", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6f4315c43dc..41083ee8e8c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7092,6 +7092,12 @@ } } }, + "webdav": { + "name": "WebDAV", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "webmin": { "name": "Webmin", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 1ce88e0f55d..87dd9bb204e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -421,6 +421,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6588b06c41..f55ea287d37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -403,6 +403,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py new file mode 100644 index 00000000000..33e0222fb34 --- /dev/null +++ b/tests/components/webdav/__init__.py @@ -0,0 +1 @@ +"""Tests for the WebDAV integration.""" diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py new file mode 100644 index 00000000000..ccd3437aaa0 --- /dev/null +++ b/tests/components/webdav/conftest.py @@ -0,0 +1,80 @@ +"""Common fixtures for the WebDAV tests.""" + +from collections.abc import AsyncIterator, Generator +from json import dumps +from unittest.mock import AsyncMock, patch + +from aiowebdav2 import Property, PropertyRequest +import pytest + +from homeassistant.components.webdav.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + +from .const import ( + BACKUP_METADATA, + MOCK_GET_PROPERTY_BACKUP_ID, + MOCK_GET_PROPERTY_METADATA_VERSION, + MOCK_LIST_WITH_INFOS, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.webdav.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + +def _get_property(path: str, request: PropertyRequest) -> Property: + """Return the property of a file.""" + if path.endswith(".json") and request.name == "metadata_version": + return MOCK_GET_PROPERTY_METADATA_VERSION + + return MOCK_GET_PROPERTY_BACKUP_ID + + +async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: + """Mock the download function.""" + if path.endswith(".json"): + yield dumps(BACKUP_METADATA).encode() + + yield b"backup data" + + +@pytest.fixture(name="webdav_client") +def mock_webdav_client() -> Generator[AsyncMock]: + """Mock the aiowebdav client.""" + with ( + patch( + "homeassistant.components.webdav.helpers.Client", + autospec=True, + ) as mock_webdav_client, + ): + mock = mock_webdav_client.return_value + mock.check.return_value = True + mock.mkdir.return_value = True + mock.list_with_infos.return_value = MOCK_LIST_WITH_INFOS + mock.download_iter.side_effect = _download_mock + mock.upload_iter.return_value = None + mock.clean.return_value = None + mock.get_property.side_effect = _get_property + yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py new file mode 100644 index 00000000000..777008b07a5 --- /dev/null +++ b/tests/components/webdav/const.py @@ -0,0 +1,52 @@ +"""Constants for WebDAV tests.""" + +from aiowebdav2 import Property + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "protected": False, + "size": 34519040, +} + +MOCK_LIST_WITH_INFOS = [ + { + "content_type": "application/x-tar", + "created": "2025-02-10T17:47:22Z", + "etag": '"84d7d000-62dcd4ce886b4"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", + "size": "2228736000", + }, + { + "content_type": "application/json", + "created": "2025-02-10T17:47:22Z", + "etag": '"8d0-62dcd4cec050a"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", + "size": "2256", + }, +] + +MOCK_GET_PROPERTY_METADATA_VERSION = Property( + namespace="homeassistant", + name="metadata_version", + value="1", +) + +MOCK_GET_PROPERTY_BACKUP_ID = Property( + namespace="homeassistant", + name="backup_id", + value="23e64aec", +) diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py new file mode 100644 index 00000000000..b02fb2e9628 --- /dev/null +++ b/tests/components/webdav/test_backup.py @@ -0,0 +1,323 @@ +"""Test the backups for WebDAV.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import Mock, patch + +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.webdav.backup import async_register_backup_agents_listener +from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> AsyncGenerator[None]: + """Set up webdav integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + 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"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + "webdav.01JKXV07ASC62D620DGYNG2R8H": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + 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"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + 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 webdav_client.clean.call_count == 2 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + 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( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert webdav_client.upload_iter.call_count == 2 + assert webdav_client.set_property_batch.call_count == 1 + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we get not found on a not existing backup on download.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + webdav_client.list_with_infos.side_effect = [MOCK_LIST_WITH_INFOS, []] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + WebDavError("Unknown path"), + "Backup operation failed: Unknown path", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + webdav_client.clean.side_effect = side_effect + + 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": {f"{DOMAIN}.{mock_config_entry.entry_id}": error} + } + + +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + webdav_client.list_with_infos.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, + webdav_client: AsyncMock, +) -> None: + """Test backup not found.""" + webdav_client.list_with_infos.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}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_raises_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we raise on 403.""" + webdav_client.list_with_infos.side_effect = UnauthorizedError( + "https://webdav.example.com" + ) + 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.entry_id}": "Authentication error" + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = AsyncMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + # make sure it's the last listener + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py new file mode 100644 index 00000000000..eb887edb1a1 --- /dev/null +++ b/tests/components/webdav/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the WebDAV config flow.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import UnauthorizedError +import pytest + +from homeassistant import config_entries +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test we get the form and create a entry on success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert result["data"] == { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + } + assert len(webdav_client.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test to handle exceptions.""" + webdav_client.check.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # reset and test for success + webdav_client.check.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_form_unauthorized( + hass: HomeAssistant, + webdav_client: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test to handle unauthorized.""" + webdav_client.check.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # reset and test for success + webdav_client.check.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> None: + """Test we get the form and create a entry on success.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 60479369b6266f924c5d7b1ff10b13394cdf5584 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:02:18 +0100 Subject: [PATCH 1185/1435] Remove name in Minecraft Server config entry (#139113) * Remove CONF_NAME in config entry * Revert config entry version from 4 back to 3 * Add data_description for address in strings.json * Use config entry title as coordinator name * Use constant as mock config entry title --- .../minecraft_server/config_flow.py | 8 +- .../components/minecraft_server/const.py | 2 - .../minecraft_server/coordinator.py | 4 +- .../minecraft_server/diagnostics.py | 4 +- .../minecraft_server/quality_scale.yaml | 4 +- .../components/minecraft_server/strings.json | 10 +- tests/components/minecraft_server/conftest.py | 8 +- .../snapshots/test_binary_sensor.ambr | 16 +-- .../snapshots/test_diagnostics.ambr | 2 - .../snapshots/test_sensor.ambr | 120 +++++++++--------- .../minecraft_server/test_binary_sensor.py | 11 +- .../minecraft_server/test_config_flow.py | 8 +- .../components/minecraft_server/test_init.py | 4 +- .../minecraft_server/test_sensor.py | 40 +++--- 14 files changed, 118 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 3ffdc33f3b2..d0f7cf5a8fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -8,10 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN DEFAULT_ADDRESS = "localhost:25565" @@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Prepare config entry data. config_data = { - CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address, } @@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, vol.Required( CONF_ADDRESS, default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index e7a58741696..35a1c0dd5a5 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,7 +1,5 @@ """Constants for the Minecraft Server integration.""" -DEFAULT_NAME = "Minecraft Server" - DOMAIN = "minecraft_server" KEY_LATENCY = "latency" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 2cd1c1a94ab..457b0700535 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -42,7 +42,7 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): super().__init__( hass=hass, - name=config_entry.data[CONF_NAME], + name=config_entry.title, config_entry=config_entry, logger=_LOGGER, update_interval=SCAN_INTERVAL, diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 61a65f9c2dd..dd94411b969 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -5,12 +5,12 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from .coordinator import MinecraftServerConfigEntry -TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} +TO_REDACT: Iterable[Any] = {CONF_ADDRESS, "players_list"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index eeda413f2ad..a866969fc33 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -6,9 +6,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow: - status: todo - comment: Check removal and replacement of name in config flow with the title (server address). + config-flow: done config-flow-test-coverage: status: todo comment: | diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index c084c9e6df0..cb4670dcac4 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -2,12 +2,14 @@ "config": { "step": { "user": { - "title": "Link your Minecraft Server", - "description": "Set up your Minecraft Server instance to allow monitoring.", "data": { - "name": "[%key:common::config_flow::data::name%]", "address": "Server address" - } + }, + "data_description": { + "address": "The hostname, IP address or SRV record of your Minecraft server, optionally including the port." + }, + "title": "Link your Minecraft Server", + "description": "Set up your Minecraft Server instance to allow monitoring." } }, "abort": { diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py index d34db5114cc..67b8bd17b3a 100644 --- a/tests/components/minecraft_server/conftest.py +++ b/tests/components/minecraft_server/conftest.py @@ -3,8 +3,8 @@ import pytest from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.components.minecraft_server.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .const import TEST_ADDRESS, TEST_CONFIG_ENTRY_ID @@ -18,8 +18,8 @@ def java_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.JAVA_EDITION, }, @@ -34,8 +34,8 @@ def bedrock_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.BEDROCK_EDITION, }, diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index 2e4bf49089c..c93a87d70d8 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -3,10 +3,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -17,10 +17,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -31,10 +31,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -45,10 +45,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr index 72d79795c6a..b722f4122f3 100644 --- a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr +++ b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr @@ -8,7 +8,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Bedrock Edition', }), 'config_entry_options': dict({ @@ -36,7 +35,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Java Edition', }), 'config_entry_options': dict({ diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index 47d638adf79..d2b044c06f5 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -2,11 +2,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -16,11 +16,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -30,11 +30,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -44,10 +44,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -57,10 +57,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -70,10 +70,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -83,10 +83,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -96,10 +96,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -109,10 +109,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -122,11 +122,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -136,7 +136,7 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -145,7 +145,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -155,11 +155,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -169,10 +169,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -182,10 +182,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -195,10 +195,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -208,11 +208,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -222,11 +222,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -236,11 +236,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -250,10 +250,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -263,10 +263,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -276,10 +276,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -289,10 +289,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -302,10 +302,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -315,10 +315,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -328,11 +328,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -342,7 +342,7 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -351,7 +351,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -361,11 +361,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -375,10 +375,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -388,10 +388,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -401,10 +401,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 6321c91d74a..77537a5e8e4 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -64,7 +64,9 @@ async def test_binary_sensor( ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -113,7 +115,9 @@ async def test_binary_sensor_update( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -167,5 +171,6 @@ async def test_binary_sensor_update_failure( async_fire_time_changed(hass) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.minecraft_server_status").state == STATE_OFF + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status").state + == STATE_OFF ) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 41817986bcf..00e25028249 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -5,9 +5,9 @@ from unittest.mock import patch from mcstatus import BedrockServer, JavaServer from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -22,7 +22,6 @@ from .const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } @@ -146,7 +145,6 @@ async def test_java_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION @@ -169,7 +167,6 @@ async def test_bedrock_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION @@ -207,6 +204,5 @@ async def test_recovery(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == USER_INPUT[CONF_ADDRESS] - assert result2["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS assert result2["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 6f7a49a190c..c00c5ec80cd 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -6,7 +6,7 @@ from mcstatus import JavaServer import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT @@ -23,6 +23,8 @@ from .const import ( from tests.common import MockConfigEntry +DEFAULT_NAME = "Minecraft Server" + TEST_UNIQUE_ID = f"{TEST_HOST}-{TEST_PORT}" SENSOR_KEYS = [ diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index ff62f8ddf36..a4cea239f7a 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -22,35 +22,35 @@ from .const import ( from tests.common import async_fire_time_changed JAVA_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", ] JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", ] BEDROCK_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_map_name", - "sensor.minecraft_server_game_mode", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_map_name", + "sensor.mc_dummyserver_com_25566_game_mode", + "sensor.mc_dummyserver_com_25566_edition", ] BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_edition", ] From 2bab7436d3498aa9ff6536240a4dc832542372b1 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 24 Feb 2025 10:07:05 -0700 Subject: [PATCH 1186/1435] Add vesync debug mode in library (#134571) * Debug mode pass through * Correct code, shouldn't have been lambda * listener for change * ruff * Update manifest.json * Reflect correct logger title * Ruff fix from merge --- homeassistant/components/vesync/__init__.py | 31 ++++++++++++++++--- homeassistant/components/vesync/const.py | 1 + homeassistant/components/vesync/manifest.json | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index f9371d44507..01f88c64bf4 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -5,8 +5,13 @@ import logging from pyvesync import VeSync from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + EVENT_LOGGING_CHANGED, + Platform, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -17,6 +22,7 @@ from .const import ( VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, + VS_LISTENERS, VS_MANAGER, ) from .coordinator import VeSyncDataCoordinator @@ -42,7 +48,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b time_zone = str(hass.config.time_zone) - manager = VeSync(username, password, time_zone) + manager = VeSync( + username=username, + password=password, + time_zone=time_zone, + debug=logging.getLogger("pyvesync.vesync").level == logging.DEBUG, + redact=True, + ) login = await hass.async_add_executor_job(manager.login) @@ -62,6 +74,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + @callback + def _async_handle_logging_changed(_event: Event) -> None: + """Handle when the logging level changes.""" + manager.debug = logging.getLogger("pyvesync.vesync").level == logging.DEBUG + + cleanup = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, _async_handle_logging_changed + ) + + hass.data[DOMAIN][VS_LISTENERS] = cleanup + async def async_new_device_discovery(service: ServiceCall) -> None: """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] @@ -87,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - + hass.data[DOMAIN][VS_LISTENERS]() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 2e51b96451c..1273ab914f8 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -22,6 +22,7 @@ exceeds the quota of 7700. VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_LISTENERS = "listeners" VS_NUMBERS = "numbers" VS_HUMIDIFIER_MODE_AUTO = "auto" diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 9e2fbcc1782..571c6ee0036 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -11,6 +11,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", - "loggers": ["pyvesync"], + "loggers": ["pyvesync.vesync"], "requirements": ["pyvesync==2.1.18"] } From 79dbc704702fd7ff1489ca16a99dfa48a9596e96 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Feb 2025 18:09:51 +0100 Subject: [PATCH 1187/1435] Fix return value for DataUpdateCoordinator._async setup (#139181) Fix return value for coodinator async setup --- homeassistant/helpers/update_coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index be765ff422d..7130264eb0d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -348,8 +348,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): only once during the first refresh. """ if self.setup_method is None: - return None - return await self.setup_method() + return + await self.setup_method() async def async_refresh(self) -> None: """Refresh data and log errors.""" From 6507955a144c006cb4cc32800ddbfc8c83728a63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 18:55:13 +0100 Subject: [PATCH 1188/1435] Fix race in WS command recorder/info (#139177) * Fix race in WS command recorder/info * Add comment * Remove unnecessary local import --- .../recorder/basic_websocket_api.py | 33 +++++++++---------- .../components/recorder/test_websocket_api.py | 27 +++++++++------ 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index 9cbc77b30c0..258f6c63a9d 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import recorder as recorder_helper from .util import get_instance @@ -23,27 +24,23 @@ def async_setup(hass: HomeAssistant) -> None: vol.Required("type"): "recorder/info", } ) -@callback -def ws_info( +@websocket_api.async_response +async def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return status of the recorder.""" - if instance := get_instance(hass): - backlog = instance.backlog - migration_in_progress = instance.migration_in_progress - migration_is_live = instance.migration_is_live - recording = instance.recording - # We avoid calling is_alive() as it can block waiting - # for the thread state lock which will block the event loop. - is_running = instance.is_running - max_backlog = instance.max_backlog - else: - backlog = None - migration_in_progress = False - migration_is_live = False - recording = False - is_running = False - max_backlog = None + # Wait for db_connected to ensure the recorder instance is created and the + # migration flags are set. + await hass.data[recorder_helper.DATA_RECORDER].db_connected + instance = get_instance(hass) + backlog = instance.backlog + migration_in_progress = instance.migration_in_progress + migration_is_live = instance.migration_is_live + recording = instance.recording + # We avoid calling is_alive() as it can block waiting + # for the thread state lock which will block the event loop. + is_running = instance.is_running + max_backlog = instance.max_backlog recorder_info = { "backlog": backlog, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8cbbb7a711b..8f93264b682 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2608,21 +2608,28 @@ async def test_recorder_info_bad_recorder_config( assert response["result"]["thread_running"] is False -async def test_recorder_info_no_instance( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +async def test_recorder_info_wait_database_connect( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: - """Test getting recorder when there is no instance.""" + """Test getting recorder info waits for recorder database connection.""" client = await hass_ws_client() - with patch( - "homeassistant.components.recorder.basic_websocket_api.get_instance", - return_value=None, - ): - await client.send_json_auto_id({"type": "recorder/info"}) + recorder_helper.async_initialize_recorder(hass) + await client.send_json_auto_id({"type": "recorder/info"}) + + async with async_test_recorder(hass): response = await client.receive_json() assert response["success"] - assert response["result"]["recording"] is False - assert response["result"]["thread_running"] is False + assert response["result"] == { + "backlog": ANY, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } async def test_recorder_info_migration_queue_exhausted( From b42973040c98eeaccefe23d88a34144cc2b891a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Feb 2025 13:01:25 -0500 Subject: [PATCH 1189/1435] Bump aiohttp to 3.11.13 (#139197) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.12...v3.11.13 --- 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 967ce98a705..335a3b1da29 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.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.12 +aiohttp==3.11.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index b43e4d284ca..1224cc0c70e 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.12", + "aiohttp==3.11.13", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 962cab71a53..1ec004d7f65 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.12 +aiohttp==3.11.13 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 1c83dab0a1aa1ee010958a94af5ba7cc00beff3a Mon Sep 17 00:00:00 2001 From: Tristan Date: Tue, 25 Feb 2025 06:29:55 +1100 Subject: [PATCH 1190/1435] Update Linkplay constants for Arylic S10+ and Arylic Up2Stream Amp 2.1 (#138198) --- homeassistant/components/linkplay/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 00bb691362b..7151ed1537a 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -25,10 +25,12 @@ MODELS_ARYLIC_A30: Final[str] = "A30" MODELS_ARYLIC_A50: Final[str] = "A50" MODELS_ARYLIC_A50S: Final[str] = "A50+" MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" +MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1" MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3" MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" +MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+" MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp" MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" MODELS_WIIM_AMP: Final[str] = "WiiM Amp" @@ -49,9 +51,10 @@ PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = { "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3), "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), + "S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P), "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP), "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1), "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), From 2451e5578a20cbb320e072a44688aaee8f0be44e Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 24 Feb 2025 19:39:04 +0000 Subject: [PATCH 1191/1435] Add support for Apps and Radios to Squeezebox Media Browser (#135009) --- .../components/squeezebox/browse_media.py | 179 ++++++++++++++++-- homeassistant/components/squeezebox/const.py | 8 +- .../components/squeezebox/media_player.py | 13 +- tests/components/squeezebox/conftest.py | 28 ++- .../squeezebox/test_media_browser.py | 171 +++++++++++++---- 5 files changed, 334 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index c0458067a23..e12d2aa8844 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +from dataclasses import dataclass, field from typing import Any from pysqueezebox import Player @@ -18,6 +19,8 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request +from .const import UNPLAYABLE_TYPES + LIBRARY = [ "Favorites", "Artists", @@ -26,9 +29,11 @@ LIBRARY = [ "Playlists", "Genres", "New Music", + "Apps", + "Radios", ] -MEDIA_TYPE_TO_SQUEEZEBOX = { +MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "Favorites": "favorites", "Artists": "artists", "Albums": "albums", @@ -41,19 +46,25 @@ MEDIA_TYPE_TO_SQUEEZEBOX = { MediaType.TRACK: "title", MediaType.PLAYLIST: "playlist", MediaType.GENRE: "genre", + "Apps": "apps", + "Radios": "radios", } -SQUEEZEBOX_ID_BY_TYPE = { +SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.ALBUM: "album_id", MediaType.ARTIST: "artist_id", MediaType.TRACK: "track_id", MediaType.PLAYLIST: "playlist_id", MediaType.GENRE: "genre_id", "Favorites": "item_id", + MediaType.APPS: "item_id", } CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -65,9 +76,14 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST}, MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK}, + MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + MediaType.APPS: {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, } -CONTENT_TYPE_TO_CHILD_TYPE = { +CONTENT_TYPE_TO_CHILD_TYPE: dict[ + str | MediaType, + str | MediaType | None, +] = { MediaType.ALBUM: MediaType.TRACK, MediaType.PLAYLIST: MediaType.PLAYLIST, MediaType.ARTIST: MediaType.ALBUM, @@ -78,15 +94,93 @@ CONTENT_TYPE_TO_CHILD_TYPE = { "Playlists": MediaType.PLAYLIST, "Genres": MediaType.GENRE, "Favorites": None, # can only be determined after inspecting the item + "Apps": MediaClass.APP, + "Radios": MediaClass.APP, + "App": None, # can only be determined after inspecting the item "New Music": MediaType.ALBUM, + MediaType.APPS: MediaType.APP, + MediaType.APP: MediaType.TRACK, } +@dataclass +class BrowseData: + """Class for browser to squeezebox mappings and other browse data.""" + + content_type_to_child_type: dict[ + str | MediaType, + str | MediaType | None, + ] = field(default_factory=dict) + content_type_media_class: dict[str | MediaType, dict[str, MediaClass | None]] = ( + field(default_factory=dict) + ) + squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict) + media_type_to_squeezebox: dict[str | MediaType, str] = field(default_factory=dict) + known_apps_radios: set[str] = field(default_factory=set) + + def __post_init__(self) -> None: + """Initialise the maps.""" + self.content_type_media_class.update(CONTENT_TYPE_MEDIA_CLASS) + self.content_type_to_child_type.update(CONTENT_TYPE_TO_CHILD_TYPE) + self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE) + self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) + + +@dataclass +class BrowseItemResponse: + """Class for response data for browse item functions.""" + + child_item_type: str | MediaType + child_media_class: dict[str, MediaClass | None] + can_expand: bool + can_play: bool + + +def _add_new_command_to_browse_data( + browse_data: BrowseData, cmd: str | MediaType, type: str +) -> None: + """Add items to maps for new apps or radios.""" + browse_data.media_type_to_squeezebox[cmd] = cmd + browse_data.squeezebox_id_by_type[cmd] = type + browse_data.content_type_media_class[cmd] = { + "item": MediaClass.DIRECTORY, + "children": MediaClass.TRACK, + } + browse_data.content_type_to_child_type[cmd] = MediaType.TRACK + + +def _build_response_apps_radios_category( + browse_data: BrowseData, + cmd: str | MediaType, +) -> BrowseItemResponse: + """Build item for App or radio category.""" + return BrowseItemResponse( + child_item_type=cmd, + child_media_class=browse_data.content_type_media_class[cmd], + can_expand=True, + can_play=False, + ) + + +def _build_response_known_app( + browse_data: BrowseData, search_type: str, item: dict[str, Any] +) -> BrowseItemResponse: + """Build item for app or radio.""" + + return BrowseItemResponse( + child_item_type=search_type, + child_media_class=browse_data.content_type_media_class[search_type], + can_play=bool(item["isaudio"] and item.get("url")), + can_expand=item["hasitems"], + ) + + async def build_item_response( entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None], browse_limit: int, + browse_data: BrowseData, ) -> BrowseMedia: """Create response payload for search described by payload.""" @@ -97,29 +191,30 @@ async def build_item_response( assert ( search_type is not None ) # async_browse_media will not call this function if search_type is None - media_class = CONTENT_TYPE_MEDIA_CLASS[search_type] + media_class = browse_data.content_type_media_class[search_type] children = None if search_id and search_id != search_type: - browse_id = (SQUEEZEBOX_ID_BY_TYPE[search_type], search_id) + browse_id = (browse_data.squeezebox_id_by_type[search_type], search_id) else: browse_id = None result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[search_type], + browse_data.media_type_to_squeezebox[search_type], limit=browse_limit, browse_id=browse_id, ) if result is not None and result.get("items"): - item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type] + item_type = browse_data.content_type_to_child_type[search_type] children = [] list_playable = [] for item in result["items"]: - item_id = str(item["id"]) + item_id = str(item.get("id", "")) item_thumbnail: str | None = None + if item_type: child_item_type: MediaType | str = item_type child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] @@ -144,6 +239,47 @@ async def build_item_response( can_expand = item["hasitems"] can_play = item["isaudio"] and item.get("url") + if search_type in ["Apps", "Radios"]: + # item["cmd"] contains the name of the command to use with the cli for the app + # add the command to the dictionaries + if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: + # Skip searches in apps as they'd need UI or if the link isn't to audio + continue + app_cmd = "app-" + item["cmd"] + + if app_cmd not in browse_data.known_apps_radios: + browse_data.known_apps_radios.add(app_cmd) + + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + + browse_item_response = _build_response_apps_radios_category( + browse_data, app_cmd + ) + + # Temporary variables until remainder of browse calls are restructured + child_item_type = browse_item_response.child_item_type + child_media_class = browse_item_response.child_media_class + can_expand = browse_item_response.can_expand + can_play = browse_item_response.can_play + + elif search_type in browse_data.known_apps_radios: + if ( + item.get("title") in ["Search", None] + or item.get("type") in UNPLAYABLE_TYPES + ): + # Skip searches in apps as they'd need UI + continue + + browse_item_response = _build_response_known_app( + browse_data, search_type, item + ) + + # Temporary variables until remainder of browse calls are restructured + child_item_type = browse_item_response.child_item_type + child_media_class = browse_item_response.child_media_class + can_expand = browse_item_response.can_expand + can_play = browse_item_response.can_play + if artwork_track_id := item.get("artwork_track_id"): if internal_request: item_thumbnail = player.generate_image_url_from_track_id( @@ -153,6 +289,8 @@ async def build_item_response( item_thumbnail = entity.get_browse_image_url( item_type, item_id, artwork_track_id ) + elif search_type in ["Apps", "Radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) else: item_thumbnail = item.get("image_url") # will not be proxied by HA @@ -176,6 +314,7 @@ async def build_item_response( assert media_class["item"] is not None if not search_id: search_id = search_type + return BrowseMedia( title=result.get("title"), media_class=media_class["item"], @@ -188,7 +327,11 @@ async def build_item_response( ) -async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: +async def library_payload( + hass: HomeAssistant, + player: Player, + browse_media: BrowseData, +) -> BrowseMedia: """Create response payload to describe contents of library.""" library_info: dict[str, Any] = { "title": "Music Library", @@ -201,10 +344,10 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: } for item in LIBRARY: - media_class = CONTENT_TYPE_MEDIA_CLASS[item] + media_class = browse_media.content_type_media_class[item] result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[item], + browse_media.media_type_to_squeezebox[item], limit=1, ) if result is not None and result.get("items") is not None: @@ -215,7 +358,7 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: media_class=media_class["children"], media_content_id=item, media_content_type=item, - can_play=item != "Favorites", + can_play=item not in ["Favorites", "Apps", "Radios"], can_expand=True, ) ) @@ -242,17 +385,23 @@ async def generate_playlist( player: Player, payload: dict[str, str], browse_limit: int, + browse_media: BrowseData, ) -> list | None: """Generate playlist from browsing payload.""" media_type = payload["search_type"] media_id = payload["search_id"] - if media_type not in SQUEEZEBOX_ID_BY_TYPE: + if media_type not in browse_media.squeezebox_id_by_type: raise BrowseError(f"Media type not supported: {media_type}") - browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id) + browse_id = (browse_media.squeezebox_id_by_type[media_type], media_id) + if media_type.startswith("app-"): + category = media_type + else: + category = "titles" + result = await player.async_browse( - "titles", limit=browse_limit, browse_id=browse_id + category, limit=browse_limit, browse_id=browse_id ) if result and "items" in result: items: list = result["items"] diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 61ec3cac2fa..5ce95d25632 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -27,7 +27,12 @@ STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" STATUS_QUERY_VERSION = "version" -SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +SQUEEZEBOX_SOURCE_STRINGS = ( + "source:", + "wavin:", + "spotify:", + "loop:", +) SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered" SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered" DISCOVERY_INTERVAL = 60 @@ -38,3 +43,4 @@ DEFAULT_BROWSE_LIMIT = 1000 DEFAULT_VOLUME_STEP = 5 ATTR_ANNOUNCE_VOLUME = "announce_volume" ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" +UNPLAYABLE_TYPES = ("text", "actions") diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 48015f86ba0..0cd539b4584 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -47,6 +47,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .browse_media import ( + BrowseData, build_item_response, generate_playlist, library_payload, @@ -240,6 +241,7 @@ class SqueezeBoxMediaPlayerEntity( model=player.model, manufacturer=_manufacturer, ) + self._browse_data = BrowseData() @callback def _handle_coordinator_update(self) -> None: @@ -530,9 +532,7 @@ class SqueezeBoxMediaPlayerEntity( "search_type": MediaType.PLAYLIST, } playlist = await generate_playlist( - self._player, - payload, - self.browse_limit, + self._player, payload, self.browse_limit, self._browse_data ) except BrowseError: # a list of urls @@ -545,9 +545,7 @@ class SqueezeBoxMediaPlayerEntity( "search_type": media_type, } playlist = await generate_playlist( - self._player, - payload, - self.browse_limit, + self._player, payload, self.browse_limit, self._browse_data ) _LOGGER.debug("Generated playlist: %s", playlist) @@ -646,7 +644,7 @@ class SqueezeBoxMediaPlayerEntity( ) if media_content_type in [None, "library"]: - return await library_payload(self.hass, self._player) + return await library_payload(self.hass, self._player, self._browse_data) if media_content_id and media_source.is_media_source_id(media_content_id): return await media_source.async_browse_media( @@ -663,6 +661,7 @@ class SqueezeBoxMediaPlayerEntity( self._player, payload, self.browse_limit, + self._browse_data, ) async def async_get_browse_image( diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9224334a716..cb77495e818 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -142,6 +142,9 @@ async def mock_async_browse( "title": "title", "playlists": "playlist", "playlist": "title", + "apps": "app", + "radios": "app", + "app-fakecommand": "track", } fake_items = [ { @@ -152,6 +155,8 @@ async def mock_async_browse( "item_type": child_types[media_type], "artwork_track_id": "b35bb9e9", "url": "file:///var/lib/squeezeboxserver/music/track_1.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 2", @@ -161,6 +166,8 @@ async def mock_async_browse( "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 3", @@ -169,6 +176,19 @@ async def mock_async_browse( "isaudio": True, "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + }, + { + "title": "Fake Invalid Item 1", + "id": FAKE_VALID_ITEM_ID + "invalid_3", + "hasitems": media_type == "favorites", + "isaudio": True, + "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, + "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + "type": "text", }, ] @@ -198,7 +218,10 @@ async def mock_async_browse( "items": fake_items, } return None - if media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values(): + if ( + media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values() + or media_type == "app-fakecommand" + ): return { "title": media_type, "items": fake_items, @@ -232,6 +255,9 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.async_play_announcement = AsyncMock( side_effect=mock_async_play_announcement ) + mock_player.generate_image_url = MagicMock( + return_value="http://lms.internal:9000/html/images/favorites.png" + ) mock_player.name = TEST_PLAYER_NAME mock_player.player_id = uuid mock_player.mode = "stop" diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index c03c1b6344d..f00ea1754fc 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -19,6 +19,8 @@ from homeassistant.components.squeezebox.browse_media import ( from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from .conftest import FAKE_VALID_ITEM_ID + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -66,56 +68,143 @@ async def test_async_browse_media_root( assert item["title"] == LIBRARY[idx] +@pytest.mark.parametrize( + ("category", "child_count"), + [ + ("Favorites", 4), + ("Artists", 4), + ("Albums", 4), + ("Playlists", 4), + ("Genres", 4), + ("New Music", 4), + ("Apps", 3), + ("Radios", 3), + ], +) async def test_async_browse_media_with_subitems( hass: HomeAssistant, config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, + category: str, + child_count: int, ) -> None: """Test each category with subitems.""" - for category in ( - "Favorites", - "Artists", - "Albums", - "Playlists", - "Genres", - "New Music", + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, ): - with patch( - "homeassistant.components.squeezebox.browse_media.is_internal_request", - return_value=False, - ): - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": "", - "media_content_type": category, - } - ) - response = await client.receive_json() - assert response["success"] - category_level = response["result"] - assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] - assert category_level["children"][0]["title"] == "Fake Item 1" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + category_level = response["result"] + assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] + assert category_level["children"][0]["title"] == "Fake Item 1" + assert len(category_level["children"]) == child_count - # Look up a subitem - search_type = category_level["children"][0]["media_content_type"] - search_id = category_level["children"][0]["media_content_id"] - await client.send_json( + # Look up a subitem + search_type = category_level["children"][0]["media_content_type"] + search_id = category_level["children"][0]["media_content_id"] + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": search_id, + "media_content_type": search_type, + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["title"] == "Fake Item 1" + + +async def test_async_browse_media_for_apps( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing for app category.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + # Look up a subitem + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "app-fakecommand", + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["children"][0]["title"] == "Fake Item 1" + assert "Fake Invalid Item 1" not in search + + +async def test_generate_playlist_for_app( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the generate_playlist for app-fakecommand media type.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + try: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { - "id": 2, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": search_id, - "media_content_type": search_type, - } + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "app-fakecommand", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + }, + blocking=True, ) - response = await client.receive_json() - assert response["success"] - search = response["result"] - assert search["title"] == "Fake Item 1" + except BrowseError: + pytest.fail("generate_playlist fails for app") async def test_async_browse_tracks( @@ -142,7 +231,7 @@ async def test_async_browse_tracks( assert response["success"] tracks = response["result"] assert tracks["title"] == "titles" - assert len(tracks["children"]) == 3 + assert len(tracks["children"]) == 4 async def test_async_browse_error( From dc92e912c2885d69071bf1721c4ea60eef0fc3f2 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 24 Feb 2025 20:59:51 +0100 Subject: [PATCH 1192/1435] Add azure_storage as backup agent (#134085) Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/microsoft.json | 1 + .../components/azure_storage/__init__.py | 82 +++++ .../components/azure_storage/backup.py | 182 ++++++++++ .../components/azure_storage/config_flow.py | 72 ++++ .../components/azure_storage/const.py | 16 + .../components/azure_storage/manifest.json | 12 + .../azure_storage/quality_scale.yaml | 133 ++++++++ .../components/azure_storage/strings.json | 48 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/azure_storage/__init__.py | 14 + tests/components/azure_storage/conftest.py | 63 ++++ tests/components/azure_storage/const.py | 36 ++ tests/components/azure_storage/test_backup.py | 317 ++++++++++++++++++ .../azure_storage/test_config_flow.py | 113 +++++++ tests/components/azure_storage/test_init.py | 54 +++ 21 files changed, 1169 insertions(+) create mode 100644 homeassistant/components/azure_storage/__init__.py create mode 100644 homeassistant/components/azure_storage/backup.py create mode 100644 homeassistant/components/azure_storage/config_flow.py create mode 100644 homeassistant/components/azure_storage/const.py create mode 100644 homeassistant/components/azure_storage/manifest.json create mode 100644 homeassistant/components/azure_storage/quality_scale.yaml create mode 100644 homeassistant/components/azure_storage/strings.json create mode 100644 tests/components/azure_storage/__init__.py create mode 100644 tests/components/azure_storage/conftest.py create mode 100644 tests/components/azure_storage/const.py create mode 100644 tests/components/azure_storage/test_backup.py create mode 100644 tests/components/azure_storage/test_config_flow.py create mode 100644 tests/components/azure_storage/test_init.py diff --git a/.strict-typing b/.strict-typing index 95eb2abb4b4..1df49300b1e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -103,6 +103,7 @@ homeassistant.components.auth.* homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.axis.* +homeassistant.components.azure_storage.* homeassistant.components.backup.* homeassistant.components.baf.* homeassistant.components.bang_olufsen.* diff --git a/CODEOWNERS b/CODEOWNERS index bb8545c46b7..87f170009f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -180,6 +180,8 @@ build.json @home-assistant/supervisor /homeassistant/components/azure_event_hub/ @eavanvalkenburg /tests/components/azure_event_hub/ @eavanvalkenburg /homeassistant/components/azure_service_bus/ @hfurubotten +/homeassistant/components/azure_storage/ @zweckj +/tests/components/azure_storage/ @zweckj /homeassistant/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core /homeassistant/components/baf/ @bdraco @jfroy diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 0e00c4a7bc3..918f67f06dd 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -6,6 +6,7 @@ "azure_devops", "azure_event_hub", "azure_service_bus", + "azure_storage", "microsoft_face_detect", "microsoft_face_identify", "microsoft_face", diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py new file mode 100644 index 00000000000..873a9ab90ca --- /dev/null +++ b/homeassistant/components/azure_storage/__init__.py @@ -0,0 +1,82 @@ +"""The Azure Storage integration.""" + +from aiohttp import ClientTimeout +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) +from azure.core.pipeline.transport._aiohttp import ( + AioHttpTransport, +) # need to import from private file, as it is not properly imported in the init +from azure.storage.blob.aio import ContainerClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) + +type AzureStorageConfigEntry = ConfigEntry[ContainerClient] + + +async def async_setup_entry( + hass: HomeAssistant, entry: AzureStorageConfigEntry +) -> bool: + """Set up Azure Storage integration.""" + # set increase aiohttp timeout for long running operations (up/download) + session = async_create_clientsession( + hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60) + ) + container_client = ContainerClient( + account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + container_name=entry.data[CONF_CONTAINER_NAME], + credential=entry.data[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=session), + ) + + try: + if not await container_client.exists(): + await container_client.create_container() + except ResourceNotFoundError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="account_not_found", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + except ClientAuthenticationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + except HttpResponseError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + + entry.runtime_data = container_client + + def _async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners)) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: AzureStorageConfigEntry +) -> bool: + """Unload an Azure Storage config entry.""" + return True diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py new file mode 100644 index 00000000000..6f39295761d --- /dev/null +++ b/homeassistant/components/azure_storage/backup.py @@ -0,0 +1,182 @@ +"""Support for Azure Storage backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import json +import logging +from typing import Any, Concatenate + +from azure.core.exceptions import HttpResponseError +from azure.storage.blob import BlobProperties + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import AzureStorageConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +METADATA_VERSION = "1" + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [AzureStorageBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + hass.data.pop(DATA_BACKUP_AGENT_LISTENERS) + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper( + self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except HttpResponseError as err: + _LOGGER.debug( + "Error during backup in %s: Status %s, message %s", + func.__name__, + err.status_code, + err.message, + exc_info=True, + ) + raise BackupAgentError( + f"Error during backup operation in {func.__name__}:" + f" Status {err.status_code}, message: {err.message}" + ) from err + + return wrapper + + +class AzureStorageBackupAgent(BackupAgent): + """Azure storage backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None: + """Initialize the Azure storage backup agent.""" + super().__init__() + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + raise BackupNotFound(f"Backup {backup_id} not found") + download_stream = await self._client.download_blob(blob.name) + return download_stream.chunks() + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + metadata = { + "metadata_version": METADATA_VERSION, + "backup_id": backup.backup_id, + "backup_metadata": json.dumps(backup.as_dict()), + } + + await self._client.upload_blob( + name=suggested_filename(backup), + metadata=metadata, + data=await open_stream(), + length=backup.size, + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + return + await self._client.delete_blob(blob.name) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups: list[AgentBackup] = [] + async for blob in self._client.list_blobs(include="metadata"): + metadata = blob.metadata + + if metadata.get("metadata_version") == METADATA_VERSION: + backups.append( + AgentBackup.from_dict(json.loads(metadata["backup_metadata"])) + ) + + return backups + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + return None + + return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"])) + + async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None: + """Find a blob by backup id.""" + async for blob in self._client.list_blobs(include="metadata"): + if ( + backup_id == blob.metadata.get("backup_id", "") + and blob.metadata.get("metadata_version") == METADATA_VERSION + ): + return blob + return None diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py new file mode 100644 index 00000000000..e5b1214fa5b --- /dev/null +++ b/homeassistant/components/azure_storage/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for Azure Storage integration.""" + +import logging +from typing import Any + +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError +from azure.core.pipeline.transport._aiohttp import ( + AioHttpTransport, +) # need to import from private file, as it is not properly imported in the init +from azure.storage.blob.aio import ContainerClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for azure storage.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User step for Azure Storage.""" + + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} + ) + container_client = ContainerClient( + account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + container_name=user_input[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + try: + await container_client.exists() + except ResourceNotFoundError: + errors["base"] = "cannot_connect" + except ClientAuthenticationError: + errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown exception occurred") + errors["base"] = "unknown" + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}", + data=user_input, + ) + + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required(CONF_ACCOUNT_NAME): str, + vol.Required( + CONF_CONTAINER_NAME, default="home-assistant-backups" + ): str, + vol.Required(CONF_STORAGE_ACCOUNT_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/const.py b/homeassistant/components/azure_storage/const.py new file mode 100644 index 00000000000..efcb338a096 --- /dev/null +++ b/homeassistant/components/azure_storage/const.py @@ -0,0 +1,16 @@ +"""Constants for the Azure Storage integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "azure_storage" + +CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key" +CONF_ACCOUNT_NAME: Final = "account_name" +CONF_CONTAINER_NAME: Final = "container_name" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json new file mode 100644 index 00000000000..8f2d8aeaca7 --- /dev/null +++ b/homeassistant/components/azure_storage/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "azure_storage", + "name": "Azure Storage", + "codeowners": ["@zweckj"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/azure_storage", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["azure-storage-blob"], + "quality_scale": "bronze", + "requirements": ["azure-storage-blob==12.24.0"] +} diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml new file mode 100644 index 00000000000..6b6f90de494 --- /dev/null +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -0,0 +1,133 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json new file mode 100644 index 00000000000..4bd4cb0dfba --- /dev/null +++ b/homeassistant/components/azure_storage/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "storage_account_key": "Storage account key", + "account_name": "Account name", + "container_name": "Container name" + }, + "data_description": { + "storage_account_key": "Storage account access key used for authorization", + "account_name": "Name of the storage account", + "container_name": "Name of the storage container to be used (will be created if it does not exist)" + }, + "description": "Set up an Azure (Blob) storage account to be used for backups.", + "title": "Add Azure storage account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "issues": { + "container_not_found": { + "title": "Storage container not found", + "description": "The storage container {container_name} has not been found in the storage account. Please re-create it manually, then fix this issue." + } + }, + "exceptions": { + "account_not_found": { + "message": "Storage account {account_name} not found" + }, + "cannot_connect": { + "message": "Can not connect to storage account {account_name}" + }, + "invalid_auth": { + "message": "Authentication failed for storage account {account_name}" + }, + "container_not_found": { + "message": "Storage container {container_name} not found" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index de581c65297..8284f77ef94 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -79,6 +79,7 @@ FLOWS = { "azure_data_explorer", "azure_devops", "azure_event_hub", + "azure_storage", "baf", "balboa", "bang_olufsen", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 41083ee8e8c..01ff9d14d90 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3800,6 +3800,12 @@ "iot_class": "cloud_push", "name": "Azure Service Bus" }, + "azure_storage": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Azure Storage" + }, "microsoft_face_detect": { "integration_type": "hub", "config_flow": false, diff --git a/mypy.ini b/mypy.ini index a04242dc66d..a6203993c87 100644 --- a/mypy.ini +++ b/mypy.ini @@ -785,6 +785,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.azure_storage.*] +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.backup.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 87dd9bb204e..3b80e4f78a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -571,6 +571,9 @@ azure-kusto-ingest==4.5.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f55ea287d37..4ec3192285d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -517,6 +517,9 @@ azure-kusto-data[aio]==4.5.1 # homeassistant.components.azure_data_explorer azure-kusto-ingest==4.5.1 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 diff --git a/tests/components/azure_storage/__init__.py b/tests/components/azure_storage/__init__.py new file mode 100644 index 00000000000..bfd2e72d979 --- /dev/null +++ b/tests/components/azure_storage/__init__.py @@ -0,0 +1,14 @@ +"""Azure Storage integration tests.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the azure_storage integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/azure_storage/conftest.py b/tests/components/azure_storage/conftest.py new file mode 100644 index 00000000000..7c583ac391e --- /dev/null +++ b/tests/components/azure_storage/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for Azure Storage tests.""" + +from collections.abc import AsyncIterator, Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.const import DOMAIN + +from .const import BACKUP_METADATA, USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.azure_storage.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture(autouse=True) +def mock_client() -> Generator[MagicMock]: + """Mock the Azure Storage client.""" + with ( + patch( + "homeassistant.components.azure_storage.config_flow.ContainerClient", + autospec=True, + ) as container_client, + patch( + "homeassistant.components.azure_storage.ContainerClient", + new=container_client, + ), + ): + client = container_client.return_value + client.exists.return_value = False + + async def async_list_blobs(): + yield BlobProperties(metadata=BACKUP_METADATA) + yield BlobProperties(metadata=BACKUP_METADATA) + + client.list_blobs.return_value = async_list_blobs() + + class MockStream: + async def chunks(self) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_blob.return_value = MockStream() + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="account/container1", + domain=DOMAIN, + data=USER_INPUT, + ) diff --git a/tests/components/azure_storage/const.py b/tests/components/azure_storage/const.py new file mode 100644 index 00000000000..4edb754f650 --- /dev/null +++ b/tests/components/azure_storage/const.py @@ -0,0 +1,36 @@ +"""Consts for Azure Storage tests.""" + +from json import dumps + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, +) +from homeassistant.components.backup import AgentBackup + +USER_INPUT = { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", +} + +TEST_BACKUP = AgentBackup( + addons=[], + 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", + name="Core 2024.12.0.dev0", + protected=False, + size=34519040, +) + +BACKUP_METADATA = { + "metadata_version": "1", + "backup_id": "23e64aec", + "backup_metadata": dumps(TEST_BACKUP.as_dict()), +} diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py new file mode 100644 index 00000000000..4dc1de0a26e --- /dev/null +++ b/tests/components/azure_storage/test_backup.py @@ -0,0 +1,317 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import ANY, Mock, patch + +from azure.core.exceptions import HttpResponseError +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.azure_storage.const import ( + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA, TEST_BACKUP + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + 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"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "extra_metadata": {}, + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = TEST_BACKUP.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"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "extra_metadata": {}, + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_get_backup_does_not_throw_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent get backup does not throw on a backup not found.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] is None + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_client.delete_blob.assert_called_once() + + +async def test_agents_delete_not_throwing_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup does not throw on a backup not found.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "random", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_client.delete_blob.call_count == 0 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + 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( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {TEST_BACKUP.backup_id}" in caplog.text + mock_client.upload_blob.assert_called_once_with( + name="Core_2024.12.0.dev0_2024-11-22_11.48_48727189.tar", + metadata=BACKUP_METADATA, + data=ANY, + length=ANY, + ) + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_client.download_blob.assert_called_once() + + +async def test_agents_error_on_download_not_found( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + + async def async_list_blobs( + metadata: dict[str, str], + ) -> AsyncGenerator[BlobProperties]: + yield BlobProperties(metadata=metadata) + + mock_client.list_blobs.side_effect = [ + async_list_blobs(BACKUP_METADATA), + async_list_blobs({}), + ] + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + assert mock_client.download_blob.call_count == 0 + + +async def test_error_during_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the error wrapper.""" + mock_client.delete_blob.side_effect = HttpResponseError("Failed to delete backup") + + 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": { + f"{DOMAIN}.{mock_config_entry.entry_id}": ( + "Error during backup operation in async_delete_backup: " + "Status None, message: Failed to delete backup" + ) + } + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py new file mode 100644 index 00000000000..ed8bbed0718 --- /dev/null +++ b/tests/components/azure_storage/test_config_flow.py @@ -0,0 +1,113 @@ +"""Test the Azure storage config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError +import pytest + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +async def __async_start_flow( + hass: HomeAssistant, +) -> ConfigFlowResult: + """Initialize the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + +async def test_flow( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow.""" + mock_client.exists.return_value = False + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +@pytest.mark.parametrize( + ("exception", "errors"), + [ + (ResourceNotFoundError, {"base": "cannot_connect"}), + (ClientAuthenticationError, {CONF_STORAGE_ACCOUNT_KEY: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, + exception: Exception, + errors: dict[str, str], +) -> None: + """Test config flow errors.""" + mock_client.exists.side_effect = exception + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == errors + + # fix and finish the test + mock_client.exists.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/azure_storage/test_init.py b/tests/components/azure_storage/test_init.py new file mode 100644 index 00000000000..ca725134737 --- /dev/null +++ b/tests/components/azure_storage/test_init.py @@ -0,0 +1,54 @@ +"""Test the Azure storage integration.""" + +from unittest.mock import MagicMock + +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + 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 + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (ClientAuthenticationError, ConfigEntryState.SETUP_ERROR), + (HttpResponseError, ConfigEntryState.SETUP_RETRY), + (ResourceNotFoundError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test various setup errors.""" + mock_client.exists.side_effect = exception() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state From a1076300c88ea56833c67e2fc730dc98a3f40ac4 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 24 Feb 2025 21:03:21 +0100 Subject: [PATCH 1193/1435] Bump onedrive quality scale to platinum (#137451) --- homeassistant/components/onedrive/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 698bc7f5ca4..5ab16402cb8 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["onedrive-personal-sdk==0.0.11"] } From 33c9f3cc7d5a40678b76971e7ced738f5f9079a7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:09:17 +0100 Subject: [PATCH 1194/1435] Bump pyloadapi to v1.4.2 (#139140) --- homeassistant/components/pyload/__init__.py | 2 +- homeassistant/components/pyload/button.py | 2 +- homeassistant/components/pyload/config_flow.py | 3 +-- homeassistant/components/pyload/manifest.json | 2 +- homeassistant/components/pyload/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 8251722de50..cf8e922d70e 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI +from pyloadapi import PyLoadAPI from homeassistant.const import ( CONF_HOST, diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index 6303ced09f0..5ee10a327d1 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index b9bfc579cfc..bc3bbc6cb34 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -7,8 +7,7 @@ import logging from typing import Any from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI -from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 4490057c8e0..134865b9d93 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], - "requirements": ["PyLoadAPI==1.4.1"] + "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index 57160cbf5c1..46a54451b9a 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.switch import ( SwitchDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index 3b80e4f78a6..d0e098a6a0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -57,7 +57,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.4.1 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ec3192285d..10c18f61725 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -54,7 +54,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.4.1 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 From 72f690d68163d55d0ff624d021a9eecffdf36ab3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 21:34:41 +0100 Subject: [PATCH 1195/1435] Add missing translations to switchbot (#139212) --- homeassistant/components/switchbot/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 9c101204dcb..c9f93cce604 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -70,6 +70,10 @@ "data": { "retry_count": "Retry count", "lock_force_nightlatch": "Force Nightlatch operation mode" + }, + "data_description": { + "retry_count": "How many times to retry sending commands to your SwitchBot devices", + "lock_force_nightlatch": "Force Nightlatch operation mode even if Nightlatch is not detected" } } } From b662d32e44e1ed4ccef75eb8b82cf58797f1166f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 22:19:18 +0100 Subject: [PATCH 1196/1435] Fix bug in check_translations fixture (#139206) * Fix bug in check_translations fixture * Fix check for ignored translation errors * Fix websocket_api test --- tests/components/conftest.py | 7 +++++-- tests/components/websocket_api/test_commands.py | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index dd6776a1cad..cf10e2b8dfd 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -624,7 +624,8 @@ async def _validate_translation( if not translation_required: return - if full_key in translation_errors: + if translation_errors.get(full_key) in {"used", "unused"}: + # This translation key is in the ignore list, mark it as used translation_errors[full_key] = "used" return @@ -864,6 +865,7 @@ async def check_translations( if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] + # Set all ignored translation keys to "unused" translation_errors = {k: "unused" for k in ignore_translations} translation_coros = set() @@ -945,10 +947,11 @@ async def check_translations( # Run final checks unused_ignore = [k for k, v in translation_errors.items() if v == "unused"] if unused_ignore: + # Some ignored translations were not used pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) for description in translation_errors.values(): - if description not in {"used", "unused"}: + if description != "used": pytest.fail(description) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2ddb5c628c7..baa939c411b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -540,6 +540,10 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 +@pytest.mark.parametrize( + "ignore_translations", + ["component.test.exceptions.custom_error.message"], +) async def test_call_service_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: From b86bb75e5ec605f07b474506ce86769979ac85ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 24 Feb 2025 23:25:24 +0100 Subject: [PATCH 1197/1435] Add missing exception translation to Home Connect (#139218) Add missing exception translation --- homeassistant/components/home_connect/__init__.py | 6 +++++- homeassistant/components/home_connect/strings.json | 3 +++ tests/components/home_connect/test_init.py | 4 +--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 51b38bf7cd3..405606c6159 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -213,7 +213,11 @@ async def _get_client_and_ha_id( break if entry is None: raise ServiceValidationError( - "Home Connect config entry not found for that device id" + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, ) ha_id = next( diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 977ad1f36f0..5072bb616dd 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -33,6 +33,9 @@ "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, + "config_entry_not_found": { + "message": "Config entry for device ID {device_id} not found" + }, "turn_on_light": { "message": "Error turning on {entity_id}: {error}" }, diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 06498f891db..6e4e428bf6a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -589,9 +589,7 @@ async def test_services_appliance_not_found( ) service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises( - ServiceValidationError, match=r"Home Connect config entry.*not found" - ): + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): await hass.services.async_call(**service_call) device_entry = device_registry.async_get_or_create( From 597c0ab9854c29054aa92a10421755917f224ecf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 25 Feb 2025 02:05:30 +0100 Subject: [PATCH 1198/1435] Configure trusted publishing for PyPI file upload (#137607) --- .github/workflows/builder.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 88f6f37d6d6..68581c58d24 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -448,6 +448,9 @@ jobs: environment: ${{ needs.init.outputs.channel }} needs: ["init", "build_base"] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository @@ -473,16 +476,13 @@ jobs: run: | # Remove dist, build, and homeassistant.egg-info # when build locally for testing! - pip install twine build + pip install build python -m build - - name: Upload package - shell: bash - run: | - export TWINE_USERNAME="__token__" - export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}" - - twine upload dist/* --skip-existing + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@v1.12.4 + with: + skip-existing: true hassfest-image: name: Build and test hassfest image From c115a7f455b4a5873e8cae767a76bf01789d7394 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 24 Feb 2025 20:20:48 -0500 Subject: [PATCH 1199/1435] Bump aiostreammagic to 2.11.0 (#139213) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 14a389587d2..88d28e256aa 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiostreammagic"], "quality_scale": "platinum", - "requirements": ["aiostreammagic==2.10.0"], + "requirements": ["aiostreammagic==2.11.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d0e098a6a0b..f18deb65b35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10c18f61725..a449ef121e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -368,7 +368,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 From 54843bb4223804388c2557fad4ad6480487e03a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 02:21:25 +0100 Subject: [PATCH 1200/1435] Add missing exception translation to Home Connect (#139223) --- homeassistant/components/home_connect/__init__.py | 8 +++++++- homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 405606c6159..3e1bd1da156 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -203,7 +203,13 @@ async def _get_client_and_ha_id( device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device_id) if device_entry is None: - raise ServiceValidationError("Device entry not found for device id") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) entry: HomeConnectConfigEntry | None = None for entry_id in device_entry.config_entries: _entry = hass.config_entries.async_get_entry(entry_id) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5072bb616dd..672ad364365 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -33,6 +33,9 @@ "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, + "device_entry_not_found": { + "message": "Device entry for device ID {device_id} not found" + }, "config_entry_not_found": { "message": "Config entry for device ID {device_id} not found" }, From 212c42ca77d987b5f0dee4536e3c04a92915a9b1 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 25 Feb 2025 01:25:31 +0000 Subject: [PATCH 1201/1435] Bump ohmepy to 1.3.2 (#139013) --- 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 c1ca2bac62f..fb11fa0dd06 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.3.0"] + "requirements": ["ohme==1.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f18deb65b35..6683ea5909b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1553,7 +1553,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.3.0 +ohme==1.3.2 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a449ef121e4..26689bfc459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1301,7 +1301,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.3.0 +ohme==1.3.2 # homeassistant.components.ollama ollama==0.4.7 From 24bb13e0d173beb78aecaa8e1dc67a45ff7107f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 09:13:10 +0100 Subject: [PATCH 1202/1435] Fix kitchen_sink statistic issues (#139228) --- .../components/kitchen_sink/__init__.py | 8 +-- .../kitchen_sink/snapshots/test_init.ambr | 52 +++++++++++++++++++ tests/components/kitchen_sink/test_init.py | 20 +++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/components/kitchen_sink/snapshots/test_init.ambr diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index eff1a1ba8b2..de8e521f0e8 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -296,7 +296,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_1", + "statistic_id": "sensor.statistics_issues_issue_1", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, @@ -308,7 +308,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_2", + "statistic_id": "sensor.statistics_issues_issue_2", "unit_of_measurement": "cats", "has_mean": True, "has_sum": False, @@ -320,7 +320,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_3", + "statistic_id": "sensor.statistics_issues_issue_3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, @@ -332,7 +332,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_4", + "statistic_id": "sensor.statistics_issues_issue_4", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr new file mode 100644 index 00000000000..b91131eb2b0 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_statistics_issues + dict({ + 'sensor.statistics_issues_issue_1': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_1', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_2': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'cats', + 'state_unit': 'dogs', + 'statistic_id': 'sensor.statistics_issues_issue_2', + 'supported_unit': 'cats', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_3': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_3', + }), + 'type': 'state_class_removed', + }), + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_3', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_4': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_4', + }), + 'type': 'no_state', + }), + ]), + }) +# --- diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 7338c1dca99..50518f89107 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from unittest.mock import ANY import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN @@ -102,6 +103,25 @@ async def test_demo_statistics_growth(hass: HomeAssistant) -> None: assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) +@pytest.mark.usefixtures("recorder_mock", "mock_history") +async def test_statistics_issues( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that the kitchen sink sum statistics causes statistics issues.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_start() + await async_wait_recording_done(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "recorder/validate_statistics"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + @pytest.mark.freeze_time("2023-10-21") @pytest.mark.usefixtures("mock_history") async def test_issues_created( From 6342d8334bf6eb94eadd7c2f40b8bb06933744dd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 09:18:41 +0100 Subject: [PATCH 1203/1435] Bump aiowebdav2 to 0.3.0 (#139202) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index a1ac779afc8..75a8d7ddfe2 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.2.2"] + "requirements": ["aiowebdav2==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6683ea5909b..7d8952bdb9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.2.2 +aiowebdav2==0.3.0 # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26689bfc459..c1bd76b715b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.2.2 +aiowebdav2==0.3.0 # homeassistant.components.webostv aiowebostv==0.7.0 From c386abd49dc4bd8decc0e716850361e739fe53cc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 25 Feb 2025 09:32:06 +0100 Subject: [PATCH 1204/1435] Bump pylamarzocco to 1.4.7 (#139231) --- homeassistant/components/lamarzocco/binary_sensor.py | 2 +- homeassistant/components/lamarzocco/manifest.json | 2 +- homeassistant/components/lamarzocco/number.py | 3 ++- homeassistant/components/lamarzocco/select.py | 3 ++- homeassistant/components/lamarzocco/sensor.py | 8 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 39bd5d4b954..a98cddcda9c 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -83,7 +83,7 @@ async def async_setup_entry( ] if ( - coordinator.device.model == MachineModel.LINEA_MINI + coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale ): entities.extend( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index afd367b0f6e..eceb2bbf53b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.6"] + "requirements": ["pylamarzocco==1.4.7"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 3b3d569a6f7..666c57c1866 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -220,7 +220,8 @@ SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( config.bbw_settings.doses[key] if config.bbw_settings else None ), supported_fn=( - lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + lambda coordinator: coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale is not None ), ), diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index bd6ac1ee04f..d8217cefaff 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -88,6 +88,7 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( MachineModel.GS3_AV, MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI, + MachineModel.LINEA_MINI_R, ), ), LaMarzoccoSelectEntityDescription( @@ -138,7 +139,7 @@ async def async_setup_entry( ] if ( - coordinator.device.model == MachineModel.LINEA_MINI + coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale ): entities.extend( diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 6287ea91a40..0d4a5e53ebe 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -80,7 +80,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( BoilerType.STEAM ].current_temperature, supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.LINEA_MINI, + not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R), ), ) @@ -125,7 +125,8 @@ SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( device.config.scale.battery if device.config.scale else 0 ), supported_fn=( - lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + lambda coordinator: coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) ), ), ) @@ -148,7 +149,8 @@ async def async_setup_entry( ] if ( - config_coordinator.device.model == MachineModel.LINEA_MINI + config_coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and config_coordinator.device.config.scale ): entities.extend( diff --git a/requirements_all.txt b/requirements_all.txt index 7d8952bdb9d..d239ac021f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,7 +2077,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1bd76b715b..b770f80c3f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1691,7 +1691,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 From bf190a8a73724e82e0acfb404291c8867256ff13 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 10:19:41 +0100 Subject: [PATCH 1205/1435] Add backup helper (#139199) * Add backup helper * Add hassio to stage 1 * Apply same changes to newly merged `webdav` and `azure_storage` to fix inflight conflict * Address comments, add tests --------- Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 17 ++--- homeassistant/components/backup/__init__.py | 27 +++---- .../components/backup/basic_websocket.py | 38 ++++++++++ homeassistant/components/backup/manager.py | 18 ++--- homeassistant/components/backup/websocket.py | 26 +------ .../components/frontend/manifest.json | 1 - homeassistant/components/hassio/backup.py | 4 +- .../components/onboarding/manifest.json | 1 - homeassistant/components/onboarding/views.py | 4 +- homeassistant/helpers/backup.py | 70 +++++++++++++++++++ script/hassfest/dependencies.py | 4 ++ tests/components/azure_storage/test_backup.py | 2 + tests/components/backup/common.py | 2 + .../backup/snapshots/test_websocket.ambr | 17 +++++ tests/components/backup/test_backup.py | 4 ++ tests/components/backup/test_websocket.py | 25 +++++++ tests/components/cloud/test_backup.py | 4 +- tests/components/google_drive/test_backup.py | 4 +- tests/components/hassio/test_backup.py | 8 +++ tests/components/hassio/test_update.py | 23 +++--- tests/components/hassio/test_websocket_api.py | 23 +++--- tests/components/kitchen_sink/test_backup.py | 4 +- tests/components/onboarding/test_views.py | 6 ++ tests/components/onedrive/test_backup.py | 4 +- tests/components/synology_dsm/test_backup.py | 5 +- tests/components/webdav/test_backup.py | 2 + tests/helpers/test_backup.py | 42 +++++++++++ 27 files changed, 289 insertions(+), 96 deletions(-) create mode 100644 homeassistant/components/backup/basic_websocket.py create mode 100644 homeassistant/helpers/backup.py create mode 100644 tests/helpers/test_backup.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9cfc1c95d8b..e25bfbe358c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -74,6 +74,7 @@ from .core_config import async_process_ha_core_config from .exceptions import HomeAssistantError from .helpers import ( area_registry, + backup, category_registry, config_validation as cv, device_registry, @@ -163,16 +164,6 @@ 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. - "backup", } # Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout. # The substage containing recorder should have no timeout, as it could cancel a database migration. @@ -206,6 +197,8 @@ STAGE_1_INTEGRATIONS = { "mqtt_eventstream", # To provide account link implementations "cloud", + # Ensure supervisor is available + "hassio", } DEFAULT_INTEGRATIONS = { @@ -905,6 +898,10 @@ async def _async_set_up_integrations( if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) + # Initialize backup + if "backup" in domains_to_setup: + backup.async_initialize_backup(hass) + stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ *( (name, domain_group & domains_to_setup, timeout) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index a5159086945..d9d1c3cc2fe 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,8 +1,8 @@ """The Backup integration.""" -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -32,6 +32,7 @@ from .manager import ( IdleEvent, IncorrectPasswordError, ManagerBackup, + ManagerStateEvent, NewBackup, RestoreBackupEvent, RestoreBackupStage, @@ -63,12 +64,12 @@ __all__ = [ "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", + "ManagerStateEvent", "NewBackup", "RestoreBackupEvent", "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", - "async_get_manager", "suggested_filename", "suggested_filename_from_name_date", ] @@ -91,7 +92,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: backup_manager = BackupManager(hass, reader_writer) hass.data[DATA_MANAGER] = backup_manager - await backup_manager.async_setup() + try: + await backup_manager.async_setup() + except Exception as err: + hass.data[DATA_BACKUP].manager_ready.set_exception(err) + raise + else: + hass.data[DATA_BACKUP].manager_ready.set_result(None) async_register_websocket_handlers(hass, with_hassio) @@ -122,15 +129,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) return True - - -@callback -def async_get_manager(hass: HomeAssistant) -> BackupManager: - """Get the backup manager instance. - - Raises HomeAssistantError if the backup integration is not available. - """ - if DATA_MANAGER not in hass.data: - raise HomeAssistantError("Backup integration is not available") - - return hass.data[DATA_MANAGER] diff --git a/homeassistant/components/backup/basic_websocket.py b/homeassistant/components/backup/basic_websocket.py new file mode 100644 index 00000000000..614dc23a927 --- /dev/null +++ b/homeassistant/components/backup/basic_websocket.py @@ -0,0 +1,38 @@ +"""Websocket commands for the Backup integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.backup import async_subscribe_events + +from .const import DATA_MANAGER +from .manager import ManagerStateEvent + + +@callback +def async_register_websocket_handlers(hass: HomeAssistant) -> None: + """Register websocket commands.""" + websocket_api.async_register_command(hass, handle_subscribe_events) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) +@websocket_api.async_response +async def handle_subscribe_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to backup events.""" + + def on_event(event: ManagerStateEvent) -> None: + connection.send_message(websocket_api.event_message(msg["id"], event)) + + if DATA_MANAGER in hass.data: + manager = hass.data[DATA_MANAGER] + on_event(manager.last_event) + connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 0f79cd79e0c..3bf31618b24 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -33,6 +33,7 @@ from homeassistant.helpers import ( integration_platform, issue_registry as ir, ) +from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util, json as json_util @@ -332,7 +333,9 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = IdleEvent() self.last_non_idle_event: ManagerStateEvent | None = None - self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] + self._backup_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_event_subscriptions async def async_setup(self) -> None: """Set up the backup manager.""" @@ -1279,19 +1282,6 @@ class BackupManager: for subscription in self._backup_event_subscriptions: subscription(event) - @callback - def async_subscribe_events( - self, - on_event: Callable[[ManagerStateEvent], None], - ) -> Callable[[], None]: - """Subscribe events.""" - - def remove_subscription() -> None: - self._backup_event_subscriptions.remove(on_event) - - self._backup_event_subscriptions.append(on_event) - return remove_subscription - def _update_issue_backup_failed(self) -> None: """Update issue registry when a backup fails.""" ir.async_create_issue( diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 5084f904ec6..8b5f35287dd 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv from .config import Day, ScheduleRecurrence from .const import DATA_MANAGER, LOGGER -from .manager import ( - DecryptOnDowloadNotSupported, - IncorrectPasswordError, - ManagerStateEvent, -) +from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError from .models import BackupNotFound, Folder @@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_restore) - websocket_api.async_register_command(hass, handle_subscribe_events) websocket_api.async_register_command(hass, handle_config_info) websocket_api.async_register_command(hass, handle_config_update) @@ -401,22 +396,3 @@ def handle_config_update( changes.pop("type") manager.config.update(**changes) connection.send_result(msg["id"]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) -@websocket_api.async_response -async def handle_subscribe_events( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Subscribe to backup events.""" - - def on_event(event: ManagerStateEvent) -> None: - connection.send_message(websocket_api.event_message(msg["id"], event)) - - manager = hass.data[DATA_MANAGER] - on_event(manager.last_event) - connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event) - connection.send_result(msg["id"]) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 499e1fbddb2..b13b33685d5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -1,7 +1,6 @@ { "domain": "frontend", "name": "Home Assistant Frontend", - "after_dependencies": ["backup"], "codeowners": ["@home-assistant/frontend"], "dependencies": [ "api", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index e7d169c142c..fe69b9e08e5 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -45,13 +45,13 @@ from homeassistant.components.backup import ( RestoreBackupStage, 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.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -751,7 +751,7 @@ async def backup_addon_before_update( async def backup_core_before_update(hass: HomeAssistant) -> None: """Prepare for updating core.""" - backup_manager = async_get_backup_manager(hass) + backup_manager = await async_get_backup_manager(hass) client = get_supervisor_client(hass) try: diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 3634894cd00..a4cf814eb2a 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,6 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["backup"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b392c6b57b0..a590588c009 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -20,7 +20,6 @@ from homeassistant.components.backup import ( BackupManager, Folder, IncorrectPasswordError, - async_get_manager as async_get_backup_manager, http as backup_http, ) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID @@ -29,6 +28,7 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component @@ -341,7 +341,7 @@ def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( raise HTTPUnauthorized try: - manager = async_get_backup_manager(request.app[KEY_HASS]) + manager = await async_get_backup_manager(request.app[KEY_HASS]) except HomeAssistantError: return self.json( {"code": "backup_disabled"}, diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py new file mode 100644 index 00000000000..4ab302749a1 --- /dev/null +++ b/homeassistant/helpers/backup.py @@ -0,0 +1,70 @@ +"""Helpers for the backup integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.components.backup import BackupManager, ManagerStateEvent + +DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") +DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") + + +@dataclass(slots=True) +class BackupData: + """Backup data stored in hass.data.""" + + backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( + default_factory=list + ) + manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) + + +@callback +def async_initialize_backup(hass: HomeAssistant) -> None: + """Initialize backup data. + + This creates the BackupData instance stored in hass.data[DATA_BACKUP] and + registers the basic backup websocket API which is used by frontend to subscribe + to backup events. + """ + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import basic_websocket + + hass.data[DATA_BACKUP] = BackupData() + basic_websocket.async_register_websocket_handlers(hass) + + +async def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_BACKUP not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + await hass.data[DATA_BACKUP].manager_ready + return hass.data[DATA_MANAGER] + + +@callback +def async_subscribe_events( + hass: HomeAssistant, + on_event: Callable[[ManagerStateEvent], None], +) -> Callable[[], None]: + """Subscribe to backup events.""" + backup_event_subscriptions = hass.data[DATA_BACKUP].backup_event_subscriptions + + def remove_subscription() -> None: + backup_event_subscriptions.remove(on_event) + + backup_event_subscriptions.append(on_event) + return remove_subscription diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index d29571eaa83..368c2f762b8 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -175,6 +175,10 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), + # The onboarding integration provides a limited backup API used during + # onboarding. The onboarding integration waits for the backup manager + # to be ready before calling any backup functionality. + ("onboarding", "backup"), } diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py index 4dc1de0a26e..7c5912a4981 100644 --- a/tests/components/azure_storage/test_backup.py +++ b/tests/components/azure_storage/test_backup.py @@ -19,6 +19,7 @@ from homeassistant.components.azure_storage.const import ( ) from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -38,6 +39,7 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index b21698bf365..e41da5c1bad 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -18,6 +18,7 @@ from homeassistant.components.backup import ( ) from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.common import mock_platform @@ -125,6 +126,7 @@ async def setup_backup_integration( ) -> dict[str, Mock]: """Set up the Backup integration.""" backups = backups or {} + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index c100a87e8cc..17e3ca8b176 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -5768,3 +5768,20 @@ 'type': 'event', }) # --- +# name: test_subscribe_event_early + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_subscribe_event_early.1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 38b61ce65ea..c9d797f4e30 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -14,6 +14,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .common import ( @@ -63,6 +64,7 @@ async def test_load_backups( side_effect: Exception | None, ) -> None: """Test load backups.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -82,6 +84,7 @@ async def test_upload( hass_client: ClientSessionGenerator, ) -> None: """Test upload backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -137,6 +140,7 @@ async def test_delete_backup( unlink_path: Path | None, ) -> None: """Test delete backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 6605674a679..9b2241882c4 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -27,6 +27,8 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -3264,6 +3266,29 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot +async def test_subscribe_event_early( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test subscribe event before backup integration has started.""" + async_initialize_backup(hass) + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + + manager.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) + ) + assert await client.receive_json() == snapshot + + @pytest.mark.parametrize( ("agent_id", "backup_id", "password"), [ diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 18793cc00bb..5220d3eccd5 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -21,6 +21,7 @@ from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.backup import async_register_backup_agents_listener from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader @@ -44,7 +45,8 @@ async def setup_integration( cloud: MagicMock, cloud_logged_in: None, ) -> AsyncGenerator[None]: - """Set up cloud integration.""" + """Set up cloud and backup integrations.""" + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 70431e2049f..2da397def5b 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -17,6 +17,7 @@ from homeassistant.components.backup import ( ) from homeassistant.components.google_drive import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID @@ -63,7 +64,8 @@ async def setup_integration( config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Set up Google Drive integration.""" + """Set up Google Drive and backup integrations.""" + async_initialize_backup(hass) config_entry.add_to_hass(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) mock_api.list_files = AsyncMock( diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index c7f400cef5c..6e4fe4dd428 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -44,6 +44,7 @@ from homeassistant.components.backup import ( from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON @@ -320,6 +321,7 @@ async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -432,6 +434,7 @@ async def test_agent_info( client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await client.send_json_auto_id({"type": "backup/agents/info"}) @@ -1287,6 +1290,7 @@ async def test_reader_writer_create_per_agent_encryption( ) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) for command in commands: @@ -2352,6 +2356,7 @@ async def test_restore_progress_after_restart( supervisor_client.jobs.get_job.return_value = get_job_result + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2375,6 +2380,7 @@ async def test_restore_progress_after_restart_report_progress( supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2457,6 +2463,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2556,6 +2563,7 @@ async def test_config_load_config_info( hass_storage.update(storage_data) + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 83af302e1ce..a3718454538 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -18,6 +18,7 @@ from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -235,6 +236,13 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), [ @@ -318,8 +326,7 @@ async def test_update_addon_with_backup( {"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() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -413,8 +420,7 @@ async def test_update_addon_with_backup_removes_old_backups( {"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() + await setup_backup_integration(hass) supervisor_client.mounts.info.return_value.default_backup_mount = None with ( @@ -588,8 +594,7 @@ async def test_update_core_with_backup( {"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() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -691,8 +696,7 @@ async def test_update_addon_with_backup_and_error( {"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() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -811,8 +815,7 @@ async def test_update_core_with_backup_and_error( {"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() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index e752b53ae7a..b695cc1794a 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -26,6 +26,7 @@ from homeassistant.components.hassio.const import ( ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component @@ -355,6 +356,13 @@ async def test_update_addon( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), [ @@ -438,8 +446,7 @@ async def test_update_addon_with_backup( {"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() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -533,8 +540,7 @@ async def test_update_addon_with_backup_removes_old_backups( {"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() + await setup_backup_integration(hass) client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -686,8 +692,7 @@ async def test_update_core_with_backup( {"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() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -766,8 +771,7 @@ async def test_update_addon_with_backup_and_error( {"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() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -834,8 +838,7 @@ async def test_update_core_with_backup_and_error( {"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() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 7c693abcda8..933979ee913 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -15,6 +15,7 @@ from homeassistant.components.backup import ( from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -35,7 +36,8 @@ async def backup_only() -> AsyncGenerator[None]: @pytest.fixture(autouse=True) async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: - """Set up Kitchen Sink integration.""" + """Set up Kitchen Sink and backup integrations.""" + async_initialize_backup(hass) with patch("homeassistant.components.backup.is_hassio", return_value=False): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 08d21a13331..b7189bda6cc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -16,6 +16,7 @@ from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import mock_storage @@ -765,6 +766,7 @@ async def test_onboarding_backup_info( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -881,6 +883,7 @@ async def test_onboarding_backup_restore( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -977,6 +980,7 @@ async def test_onboarding_backup_restore_error( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -1020,6 +1024,7 @@ async def test_onboarding_backup_restore_unexpected_error( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -1045,6 +1050,7 @@ async def test_onboarding_backup_upload( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index c307e5190c1..a81eb03a51c 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -21,6 +21,7 @@ from homeassistant.components.onedrive.backup import ( from homeassistant.components.onedrive.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -35,7 +36,8 @@ async def setup_backup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> AsyncGenerator[None]: - """Set up onedrive integration.""" + """Set up onedrive and backup integrations.""" + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 8e98f4dffa9..24cfe29f52b 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader @@ -164,7 +165,8 @@ async def setup_dsm_with_filestation( hass: HomeAssistant, mock_dsm_with_filestation: MagicMock, ): - """Mock setup of synology dsm config entry.""" + """Mock setup of synology dsm config entry and backup integration.""" + async_initialize_backup(hass) with ( patch( "homeassistant.components.synology_dsm.common.SynologyDSM", @@ -222,6 +224,7 @@ async def test_agents_not_loaded( ) -> None: """Test backup agent with no loaded config entry.""" with patch("homeassistant.components.backup.is_hassio", return_value=False): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index b02fb2e9628..2219e92f700 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -13,6 +13,7 @@ from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup from homeassistant.components.webdav.backup import async_register_backup_agents_listener from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS @@ -30,6 +31,7 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py new file mode 100644 index 00000000000..10ff5cb855f --- /dev/null +++ b/tests/helpers/test_backup.py @@ -0,0 +1,42 @@ +"""The tests for the backup helpers.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import backup as backup_helper +from homeassistant.setup import async_setup_component + + +async def test_async_get_manager(hass: HomeAssistant) -> None: + """Test async_get_manager.""" + backup_helper.async_initialize_backup(hass) + task = asyncio.create_task(backup_helper.async_get_manager(hass)) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + manager = await task + assert manager is hass.data[backup_helper.DATA_MANAGER] + + +async def test_async_get_manager_no_backup(hass: HomeAssistant) -> None: + """Test async_get_manager when the backup integration is not enabled.""" + with pytest.raises(HomeAssistantError, match="Backup integration is not available"): + await backup_helper.async_get_manager(hass) + + +async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> None: + """Test test_async_get_manager when the backup integration can't be set up.""" + backup_helper.async_initialize_backup(hass) + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_setup", + side_effect=Exception("Boom!"), + ): + assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) + with ( + pytest.raises(Exception, match="Boom!"), + ): + await backup_helper.async_get_manager(hass) From d197acc0692c7cf115aa74416ba318b6a5817104 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 11:46:40 +0100 Subject: [PATCH 1206/1435] Reduce requests made by webdav (#139238) * Reduce requests made by webdav * Update homeassistant/components/webdav/backup.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/webdav/backup.py | 70 +++++++++++++---------- tests/components/webdav/conftest.py | 19 +----- tests/components/webdav/const.py | 49 +++++----------- tests/components/webdav/test_backup.py | 38 ++++++++++-- 4 files changed, 90 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index 2c19ca450e3..a51866fde61 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -95,6 +95,23 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: return f"{base_name}.tar", f"{base_name}.metadata.json" +def _is_current_metadata_version(properties: list[Property]) -> bool: + """Check if any property is of the current metadata version.""" + return any( + prop.value == METADATA_VERSION + for prop in properties + if prop.namespace == "homeassistant" and prop.name == "metadata_version" + ) + + +def _backup_id_from_properties(properties: list[Property]) -> str | None: + """Return the backup ID from properties.""" + for prop in properties: + if prop.namespace == "homeassistant" and prop.name == "backup_id": + return prop.value + return None + + class WebDavBackupAgent(BackupAgent): """Backup agent interface.""" @@ -217,7 +234,7 @@ class WebDavBackupAgent(BackupAgent): metadata_files = await self._list_metadata_files() return [ await self._download_metadata(metadata_file) - for metadata_file in metadata_files + for metadata_file in metadata_files.values() ] @handle_backup_errors @@ -229,40 +246,33 @@ class WebDavBackupAgent(BackupAgent): """Return a backup.""" return await self._find_backup_by_id(backup_id) - async def _list_metadata_files(self) -> list[str]: + async def _list_metadata_files(self) -> dict[str, str]: """List metadata files.""" - files = await self._client.list_with_infos(self._backup_path) - return [ - file["path"] - for file in files - if file["path"].endswith(".json") - and await self._is_current_metadata_version(file["path"]) - ] - - async def _is_current_metadata_version(self, path: str) -> bool: - """Check if is current metadata version.""" - metadata_version = await self._client.get_property( - path, - PropertyRequest( - namespace="homeassistant", - name="metadata_version", - ), - ) - return metadata_version.value == METADATA_VERSION if metadata_version else False - - async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: - """Find a backup by its backup ID on remote.""" - metadata_files = await self._list_metadata_files() - for metadata_file in metadata_files: - remote_backup_id = await self._client.get_property( - metadata_file, + files = await self._client.list_with_properties( + self._backup_path, + [ + PropertyRequest( + namespace="homeassistant", + name="metadata_version", + ), PropertyRequest( namespace="homeassistant", name="backup_id", ), - ) - if remote_backup_id and remote_backup_id.value == backup_id: - return await self._download_metadata(metadata_file) + ], + ) + return { + backup_id: file_name + for file_name, properties in files.items() + if file_name.endswith(".json") and _is_current_metadata_version(properties) + if (backup_id := _backup_id_from_properties(properties)) + } + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + if metadata_file := metadata_files.get(backup_id): + return await self._download_metadata(metadata_file) return None diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index ccd3437aaa0..4fdd6fb7870 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -4,18 +4,12 @@ from collections.abc import AsyncIterator, Generator from json import dumps from unittest.mock import AsyncMock, patch -from aiowebdav2 import Property, PropertyRequest import pytest from homeassistant.components.webdav.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME -from .const import ( - BACKUP_METADATA, - MOCK_GET_PROPERTY_BACKUP_ID, - MOCK_GET_PROPERTY_METADATA_VERSION, - MOCK_LIST_WITH_INFOS, -) +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from tests.common import MockConfigEntry @@ -44,14 +38,6 @@ def mock_config_entry() -> MockConfigEntry: ) -def _get_property(path: str, request: PropertyRequest) -> Property: - """Return the property of a file.""" - if path.endswith(".json") and request.name == "metadata_version": - return MOCK_GET_PROPERTY_METADATA_VERSION - - return MOCK_GET_PROPERTY_BACKUP_ID - - async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: """Mock the download function.""" if path.endswith(".json"): @@ -72,9 +58,8 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock = mock_webdav_client.return_value mock.check.return_value = True mock.mkdir.return_value = True - mock.list_with_infos.return_value = MOCK_LIST_WITH_INFOS + mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None - mock.get_property.side_effect = _get_property yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 777008b07a5..52cad9a163b 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -16,37 +16,18 @@ BACKUP_METADATA = { "size": 34519040, } -MOCK_LIST_WITH_INFOS = [ - { - "content_type": "application/x-tar", - "created": "2025-02-10T17:47:22Z", - "etag": '"84d7d000-62dcd4ce886b4"', - "isdir": "False", - "modified": "Mon, 10 Feb 2025 17:47:22 GMT", - "name": "None", - "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", - "size": "2228736000", - }, - { - "content_type": "application/json", - "created": "2025-02-10T17:47:22Z", - "etag": '"8d0-62dcd4cec050a"', - "isdir": "False", - "modified": "Mon, 10 Feb 2025 17:47:22 GMT", - "name": "None", - "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", - "size": "2256", - }, -] - -MOCK_GET_PROPERTY_METADATA_VERSION = Property( - namespace="homeassistant", - name="metadata_version", - value="1", -) - -MOCK_GET_PROPERTY_BACKUP_ID = Property( - namespace="homeassistant", - name="backup_id", - value="23e64aec", -) +MOCK_LIST_WITH_PROPERTIES = { + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ + Property( + namespace="homeassistant", + name="backup_id", + value="23e64aec", + ), + Property( + namespace="homeassistant", + name="metadata_version", + value="1", + ), + ], +} diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index 2219e92f700..c20e73cc786 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator from io import StringIO from unittest.mock import Mock, patch +from aiowebdav2 import Property from aiowebdav2.exceptions import UnauthorizedError, WebDavError import pytest @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -210,7 +211,7 @@ async def test_error_on_agents_download( """Test we get not found on a not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] - webdav_client.list_with_infos.side_effect = [MOCK_LIST_WITH_INFOS, []] + webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] resp = await client.get( f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" @@ -261,7 +262,7 @@ async def test_agents_delete_not_found_does_not_throw( webdav_client: AsyncMock, ) -> None: """Test agent delete backup.""" - webdav_client.list_with_infos.return_value = [] + webdav_client.list_with_properties.return_value = {} client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -282,7 +283,7 @@ async def test_agents_backup_not_found( webdav_client: AsyncMock, ) -> None: """Test backup not found.""" - webdav_client.list_with_infos.return_value = [] + webdav_client.list_with_properties.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}) @@ -299,7 +300,7 @@ async def test_raises_on_403( mock_config_entry: MockConfigEntry, ) -> None: """Test we raise on 403.""" - webdav_client.list_with_infos.side_effect = UnauthorizedError( + webdav_client.list_with_properties.side_effect = UnauthorizedError( "https://webdav.example.com" ) backup_id = BACKUP_METADATA["backup_id"] @@ -323,3 +324,30 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: remove_listener() assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None + + +async def test_metadata_misses_backup_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test getting a backup when metadata has backup id property.""" + MOCK_LIST_WITH_PROPERTIES[ + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json" + ] = [ + Property( + namespace="homeassistant", + name="metadata_version", + value="1", + ) + ] + webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + + 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"]["backup"] is None From 661b55d6eb62531389513f93735bbcf922899fa2 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 25 Feb 2025 12:06:24 +0100 Subject: [PATCH 1207/1435] Add Homee valve platform (#139188) --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/strings.json | 5 + homeassistant/components/homee/valve.py | 81 +++++++++++++ tests/components/homee/fixtures/valve.json | 51 ++++++++ .../homee/snapshots/test_valve.ambr | 51 ++++++++ tests/components/homee/test_sensor.py | 25 ++-- tests/components/homee/test_valve.py | 110 ++++++++++++++++++ 7 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/homee/valve.py create mode 100644 tests/components/homee/fixtures/valve.json create mode 100644 tests/components/homee/snapshots/test_valve.ambr create mode 100644 tests/components/homee/test_valve.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 0e4959c35ac..c576fa6d23c 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index f7e24acff99..a78e12341a3 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -205,6 +205,11 @@ "watchdog": { "name": "Watchdog" } + }, + "valve": { + "valve_position": { + "name": "Valve position" + } } }, "exceptions": { diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py new file mode 100644 index 00000000000..b54d6334263 --- /dev/null +++ b/homeassistant/components/homee/valve.py @@ -0,0 +1,81 @@ +"""The Homee valve platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +VALVE_DESCRIPTIONS = { + AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription( + key="valve_position", + device_class=ValveDeviceClass.WATER, + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the valve component.""" + + async_add_entities( + HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in VALVE_DESCRIPTIONS + ) + + +class HomeeValve(HomeeEntity, ValveEntity): + """Representation of a Homee valve.""" + + _attr_reports_position = True + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: ValveEntityDescription, + ) -> None: + """Initialize a Homee valve entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + + @property + def supported_features(self) -> ValveEntityFeature: + """Return the supported features.""" + if self._attribute.editable: + return ValveEntityFeature.SET_POSITION + return ValveEntityFeature(0) + + @property + def current_valve_position(self) -> int | None: + """Return the current valve position.""" + return int(self._attribute.current_value) + + @property + def is_closing(self) -> bool: + """Return if the valve is closing.""" + return self._attribute.target_value < self._attribute.current_value + + @property + def is_opening(self) -> bool: + """Return if the valve is opening.""" + return self._attribute.target_value > self._attribute.current_value + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.async_set_value(position) diff --git a/tests/components/homee/fixtures/valve.json b/tests/components/homee/fixtures/valve.json new file mode 100644 index 00000000000..2b622cca6b1 --- /dev/null +++ b/tests/components/homee/fixtures/valve.json @@ -0,0 +1,51 @@ +{ + "id": 1, + "name": "Test Valve", + "profile": 3011, + "image": "nodeicon_valve", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_valve.ambr b/tests/components/homee/snapshots/test_valve.ambr new file mode 100644 index 00000000000..c76ecc6e780 --- /dev/null +++ b/tests/components/homee/snapshots/test_valve.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_valve_snapshot[valve.test_valve_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.test_valve_valve_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'valve_position', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_valve_snapshot[valve.test_valve_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'water', + 'friendly_name': 'Test Valve Valve position', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.test_valve_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 0f66709c532..a2ba991c49b 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -1,9 +1,8 @@ """Test homee sensors.""" -from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.homee.const import ( @@ -12,13 +11,18 @@ from homeassistant.components.homee.const import ( WINDOW_MAP, WINDOW_MAP_REVERSED, ) -from homeassistant.const import LIGHT_LUX +from homeassistant.const import LIGHT_LUX, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import async_update_attribute_value, build_mock_node, setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" async def test_up_down_values( @@ -110,19 +114,12 @@ async def test_sensor_snapshot( mock_homee: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test the multisensor snapshot.""" mock_homee.nodes = [build_mock_node("sensors.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) - entity_registry.async_update_entity( - "sensor.test_multisensor_node_state", disabled_by=None - ) - await hass.async_block_till_done() - freezer.tick(timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_valve.py b/tests/components/homee/test_valve.py new file mode 100644 index 00000000000..166b52cc07b --- /dev/null +++ b/tests/components/homee/test_valve.py @@ -0,0 +1,110 @@ +"""Test Homee valves.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + ValveEntityFeature, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_valve_set_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set valve position service.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test_valve_valve_position", "position": 100}, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 100) + + +@pytest.mark.parametrize( + ("current_value", "target_value", "state"), + [ + (0.0, 0.0, STATE_CLOSED), + (0.0, 100.0, STATE_OPENING), + (100.0, 0.0, STATE_CLOSING), + (100.0, 100.0, STATE_OPEN), + ], +) +async def test_opening_closing( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + current_value: float, + target_value: float, + state: str, +) -> None: + """Test if opening/closing is detected correctly.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + valve.current_value = current_value + valve.target_value = target_value + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + assert hass.states.get("valve.test_valve_valve_position").state == state + + +async def test_supported_features( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test supported features.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature.SET_POSITION + + valve.editable = 0 + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature(0) + + +async def test_valve_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the valve snapshots.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.VALVE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 051cc41d4f27c2a3bb5b422783a7b9d400befb55 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Tue, 25 Feb 2025 12:35:47 +0100 Subject: [PATCH 1208/1435] Fix units for LCN sensor (#138940) --- homeassistant/components/lcn/sensor.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index ee87ed2a91b..7783df8679a 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Iterable from functools import partial from itertools import chain -from typing import cast import pypck @@ -18,6 +17,11 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_SOURCE, CONF_UNIT_OF_MEASUREMENT, + LIGHT_LUX, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfSpeed, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -47,6 +51,17 @@ DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, } +UNIT_OF_MEASUREMENT_MAPPING = { + pypck.lcn_defs.VarUnit.CELSIUS: UnitOfTemperature.CELSIUS, + pypck.lcn_defs.VarUnit.KELVIN: UnitOfTemperature.KELVIN, + pypck.lcn_defs.VarUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + pypck.lcn_defs.VarUnit.LUX_T: LIGHT_LUX, + pypck.lcn_defs.VarUnit.LUX_I: LIGHT_LUX, + pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND, + pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, + pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, +} + def add_lcn_entities( config_entry: ConfigEntry, @@ -103,8 +118,10 @@ class LcnVariableSensor(LcnEntity, SensorEntity): config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT] ) - self._attr_native_unit_of_measurement = cast(str, self.unit.value) - self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit, None) + self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAPPING.get( + self.unit + ) + self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" From 48d3dd88a17826bd4ee227efc336515616559731 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 25 Feb 2025 11:36:08 +0000 Subject: [PATCH 1209/1435] Add Ohme voltage and slot list sensor (#139203) * Bump ohmepy to 1.3.1 * Bump ohmepy to 1.3.2 * Add voltage and slot list sensor * CI fixes * Change slot list sensor name * Fix snapshot tests --- homeassistant/components/ohme/icons.json | 3 + homeassistant/components/ohme/sensor.py | 15 +++ homeassistant/components/ohme/strings.json | 3 + .../ohme/snapshots/test_sensor.ambr | 99 +++++++++++++++++++ 4 files changed, 120 insertions(+) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index ade48b4f80f..9771b0bf5c2 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -31,6 +31,9 @@ }, "ct_current": { "default": "mdi:gauge" + }, + "slot_list": { + "default": "mdi:calendar-clock" } }, "switch": { diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 1e0572fe858..d0425040b53 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -15,7 +15,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + STATE_UNKNOWN, UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, ) @@ -66,6 +68,13 @@ SENSOR_CHARGE_SESSION = [ state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda client: client.energy, ), + OhmeSensorDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client: client.power.volts, + ), OhmeSensorDescription( key="battery", translation_key="vehicle_battery", @@ -74,6 +83,12 @@ SENSOR_CHARGE_SESSION = [ suggested_display_precision=0, value_fn=lambda client: client.battery, ), + OhmeSensorDescription( + key="slot_list", + translation_key="slot_list", + value_fn=lambda client: ", ".join(str(x) for x in client.slots) + or STATE_UNKNOWN, + ), ] SENSOR_ADVANCED_SETTINGS = [ diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 46ccfca71fd..387b28565b2 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -85,6 +85,9 @@ }, "vehicle_battery": { "name": "Vehicle battery" + }, + "slot_list": { + "name": "Charge slots" } }, "switch": { diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index fc28b3b011c..9cef4bfffd9 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_sensors[sensor.ohme_home_pro_charge_slots-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + '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 slots', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slot_list', + 'unique_id': 'chargerid_slot_list', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_charge_slots-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Charge slots', + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.ohme_home_pro_ct_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -327,3 +374,55 @@ 'state': '80', }) # --- +# name: test_sensors[sensor.ohme_home_pro_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Ohme Home Pro Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- From 01fb6841da27c4dbec10b4ecc93aa2787b1b61de Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 12:36:20 +0100 Subject: [PATCH 1210/1435] Initiate source list as instance variable in Volumio (#139243) --- homeassistant/components/volumio/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 514f1ad9221..773a125d483 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -70,7 +70,6 @@ class Volumio(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA ) - _attr_source_list = [] def __init__(self, volumio, uid, name, info): """Initialize the media player.""" @@ -78,6 +77,7 @@ class Volumio(MediaPlayerEntity): unique_id = uid self._state = {} self.thumbnail_cache = {} + self._attr_source_list = [] self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, From 9e063fd77c3a11d6f7881303a0105fb7972aa912 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 12:36:59 +0100 Subject: [PATCH 1211/1435] `logbook.log` action: Make description of `name` field UI-friendly (#139200) --- homeassistant/components/logbook/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index 27ad49b0e3a..5a38b57a9b7 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -7,7 +7,7 @@ "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", - "description": "Custom name for an entity, can be referenced using an `entity_id`." + "description": "Custom name for an entity, can be referenced using the 'Entity ID' field." }, "message": { "name": "Message", From cea5cda881cb25300d0bd9e78998af31f519428d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 12:47:18 +0100 Subject: [PATCH 1212/1435] Treat "Twist Assist" & "Block to Block" as feature names and add descriptions in Z-Wave (#139239) Treat "Twist Assist" & "Block to Block" as feature names and add descriptions - name-case both "Twist Assist" and "Block to Block" so those feature names don't get translated - for proper translation of both features add useful descriptions of what they actually do - fix sentence-casing on "Operation type" --- homeassistant/components/zwave_js/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index e845cc28707..8f23fee4447 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -516,8 +516,8 @@ "name": "Auto relock time" }, "block_to_block": { - "description": "Enable block-to-block functionality.", - "name": "Block to block" + "description": "Whether the lock should run the motor until it hits resistance.", + "name": "Block to Block" }, "hold_and_release_time": { "description": "Duration in seconds the latch stays retracted.", @@ -529,11 +529,11 @@ }, "operation_type": { "description": "The operation type of the lock.", - "name": "Operation Type" + "name": "Operation type" }, "twist_assist": { - "description": "Enable Twist Assist.", - "name": "Twist assist" + "description": "Whether the motor should help in locking and unlocking.", + "name": "Twist Assist" } }, "name": "Set lock configuration" From bc7f5f39818007c02972c300c0df799089b1d62a Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 25 Feb 2025 20:58:01 +0900 Subject: [PATCH 1213/1435] Add climate's swing mode to LG ThinQ (#137619) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 52 +++++++++++++++++++ .../lg_thinq/snapshots/test_climate.ambr | 22 +++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index ff57709f9a8..063705f5d0d 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -12,6 +12,8 @@ from thinqconnect.integration import ExtendedProperty from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + SWING_OFF, + SWING_ON, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -73,6 +75,13 @@ HVAC_TO_STR: dict[HVACMode, str] = { THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"] +STR_TO_SWING = { + "true": SWING_ON, + "false": SWING_OFF, +} + +SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()} + _LOGGER = logging.getLogger(__name__) @@ -142,6 +151,14 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + # Supports swing mode. + if self.data.swing_modes: + self._attr_swing_modes = [SWING_ON, SWING_OFF] + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self.data.swing_horizontal_modes: + self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF] + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE def _update_status(self) -> None: """Update status itself.""" @@ -150,6 +167,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): # Update fan, hvac and preset mode. if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self.data.fan_mode + if self.supported_features & ClimateEntityFeature.SWING_MODE: + self._attr_swing_mode = STR_TO_SWING.get(self.data.swing_mode) + if self.supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE: + self._attr_swing_horizontal_mode = STR_TO_SWING.get( + self.data.swing_horizontal_mode + ) + if self.data.is_on: hvac_mode = self._requested_hvac_mode or self.data.hvac_mode if hvac_mode in STR_TO_HVAC: @@ -268,6 +292,34 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.coordinator.api.async_set_fan_mode(self.property_id, fan_mode) ) + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new swing mode.""" + _LOGGER.debug( + "[%s:%s] async_set_swing_mode: %s", + self.coordinator.device_name, + self.property_id, + swing_mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_swing_mode( + self.property_id, SWING_TO_STR.get(swing_mode) + ) + ) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing horizontal mode.""" + _LOGGER.debug( + "[%s:%s] async_set_swing_horizontal_mode: %s", + self.coordinator.device_name, + self.property_id, + swing_horizontal_mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_swing_horizontal_mode( + self.property_id, SWING_TO_STR.get(swing_horizontal_mode) + ) + ) + def _round_by_step(self, temperature: float) -> float: """Round the value by step.""" if ( diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index e2fcc2540f3..db57e824487 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -20,6 +20,14 @@ 'preset_modes': list([ 'air_clean', ]), + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_modes': list([ + 'on', + 'off', + ]), 'target_temp_step': 1, }), 'config_entry_id': , @@ -44,7 +52,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -73,7 +81,17 @@ 'preset_modes': list([ 'air_clean', ]), - 'supported_features': , + 'supported_features': , + 'swing_horizontal_mode': 'off', + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_mode': 'off', + 'swing_modes': list([ + 'on', + 'off', + ]), 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 1, From 694a77fe3c1f7f89510a9ed80b02fe4738a294cb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 13:24:32 +0100 Subject: [PATCH 1214/1435] Bump aiowithings to 3.1.6 (#139242) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 4c78e077d21..232997da054 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.5"] + "requirements": ["aiowithings==3.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index d239ac021f9..1274cd99deb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ aiowebdav2==0.3.0 aiowebostv==0.7.0 # homeassistant.components.withings -aiowithings==3.1.5 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b770f80c3f1..6e3238a5fe7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ aiowebdav2==0.3.0 aiowebostv==0.7.0 # homeassistant.components.withings -aiowithings==3.1.5 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 From 2509353221182f1db94a6e25dd25f8b335e13169 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:40:21 +0100 Subject: [PATCH 1215/1435] Add update reward action to Habitica integration (#139157) --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 7 + homeassistant/components/habitica/services.py | 148 +++++++++- .../components/habitica/services.yaml | 40 +++ .../components/habitica/strings.json | 72 ++++- tests/components/habitica/conftest.py | 7 + .../habitica/fixtures/create_tag.json | 8 + .../components/habitica/fixtures/reward.json | 27 ++ tests/components/habitica/fixtures/tasks.json | 5 +- .../habitica/snapshots/test_sensor.ambr | 4 + .../habitica/snapshots/test_services.ambr | 8 + tests/components/habitica/test_services.py | 267 +++++++++++++++++- 12 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 tests/components/habitica/fixtures/create_tag.json create mode 100644 tests/components/habitica/fixtures/reward.json diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 5eb616142e5..5e18477d142 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -35,6 +35,10 @@ ATTR_TYPE = "type" ATTR_PRIORITY = "priority" ATTR_TAG = "tag" ATTR_KEYWORD = "keyword" +ATTR_REMOVE_TAG = "remove_tag" +ATTR_ALIAS = "alias" +ATTR_PRIORITY = "priority" +ATTR_COST = "cost" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -50,6 +54,7 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" +SERVICE_UPDATE_REWARD = "update_reward" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 6ae6ebd728b..e119b063aa5 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -217,6 +217,13 @@ "sections": { "filter": "mdi:calendar-filter" } + }, + "update_reward": { + "service": "mdi:treasure-chest", + "sections": { + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 59bcc8cc7cc..16bbeef9073 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -4,7 +4,8 @@ from __future__ import annotations from dataclasses import asdict import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from uuid import UUID from aiohttp import ClientError from habiticalib import ( @@ -13,6 +14,7 @@ from habiticalib import ( NotAuthorizedError, NotFoundError, Skill, + Task, TaskData, TaskPriority, TaskType, @@ -20,6 +22,7 @@ from habiticalib import ( ) import voluptuous as vol +from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( @@ -34,14 +37,17 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( + ATTR_ALIAS, ATTR_ARGS, ATTR_CONFIG_ENTRY, + ATTR_COST, ATTR_DATA, ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, ATTR_PATH, ATTR_PRIORITY, + ATTR_REMOVE_TAG, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, @@ -61,6 +67,7 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_REWARD, ) from .coordinator import HabiticaConfigEntry @@ -104,6 +111,21 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) +SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_RENAME): cv.string, + vol.Optional(ATTR_DESCRIPTION): cv.string, + vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_ALIAS): vol.All( + cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") + ), + vol.Optional(ATTR_COST): vol.Coerce(float), + } +) + SERVICE_GET_TASKS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -516,6 +538,130 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result + async def update_task(call: ServiceCall) -> ServiceResponse: + """Update task action.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + await coordinator.async_refresh() + + try: + current_task = next( + task + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is TaskType.REWARD + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + task_id = current_task.id + if TYPE_CHECKING: + assert task_id + data = Task() + + if rename := call.data.get(ATTR_RENAME): + data["text"] = rename + + if (description := call.data.get(ATTR_DESCRIPTION)) is not None: + data["notes"] = description + + tags = cast(list[str], call.data.get(ATTR_TAG)) + remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) + + if tags or remove_tags: + update_tags = set(current_task.tags) + user_tags = { + tag.name.lower(): tag.id + for tag in coordinator.data.user.tags + if tag.id and tag.name + } + + if tags: + # Creates new tag if it doesn't exist + async def create_tag(tag_name: str) -> UUID: + tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id + if TYPE_CHECKING: + assert tag_id + return tag_id + + try: + update_tags.update( + { + user_tags.get(tag_name.lower()) + or (await create_tag(tag_name)) + for tag_name in tags + } + ) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + if remove_tags: + update_tags.difference_update( + { + user_tags[tag_name.lower()] + for tag_name in remove_tags + if tag_name.lower() in user_tags + } + ) + + data["tags"] = list(update_tags) + + if (alias := call.data.get(ATTR_ALIAS)) is not None: + data["alias"] = alias + + if (cost := call.data.get(ATTR_COST)) is not None: + data["value"] = cost + + try: + response = await coordinator.habitica.update_task(task_id, data) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return response.data.to_dict(omit_none=True) + + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_REWARD, + update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index f3095518290..b8479c1eeec 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -140,3 +140,43 @@ get_tasks: required: false selector: text: +update_reward: + fields: + config_entry: *config_entry + task: *task + rename: + selector: + text: + description: + required: false + selector: + text: + multiline: true + cost: + required: false + selector: + number: + min: 0 + step: 0.01 + unit_of_measurement: "🪙" + mode: box + tag_options: + collapsed: true + fields: + tag: + required: false + selector: + text: + multiple: true + remove_tag: + required: false + selector: + text: + multiple: true + developer_options: + collapsed: true + fields: + alias: + required: false + selector: + text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 396a10e05f9..75558cea078 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -7,7 +7,23 @@ "unit_tasks": "tasks", "unit_health_points": "HP", "unit_mana_points": "MP", - "unit_experience_points": "XP" + "unit_experience_points": "XP", + "config_entry_description": "Select the Habitica account to update a task.", + "task_description": "The name (or task ID) of the task you want to update.", + "rename_name": "Rename", + "rename_description": "The new title for the Habitica task.", + "description_name": "Update description", + "description_description": "The new description for the Habitica task.", + "tag_name": "Add tags", + "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", + "remove_tag_name": "Remove tags", + "remove_tag_description": "Remove tags from the Habitica task.", + "alias_name": "Task alias", + "alias_description": "A task alias can be used instead of the name or task ID. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.", + "developer_options_name": "Advanced settings", + "developer_options_description": "Additional features available in developer mode.", + "tag_options_name": "Tags", + "tag_options_description": "Add or remove tags from a task." }, "config": { "abort": { @@ -457,6 +473,12 @@ }, "authentication_failed": { "message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token" + }, + "frequency_not_weekly": { + "message": "Unable to update task, weekly repeat settings apply only to weekly recurring dailies." + }, + "frequency_not_monthly": { + "message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies." } }, "issues": { @@ -651,6 +673,54 @@ "description": "Use the optional filters to narrow the returned tasks." } } + }, + "update_reward": { + "name": "Update a reward", + "description": "Updates a specific reward for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a reward." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::task_description%]" + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "description": { + "name": "[%key:component::habitica::common::description_name%]", + "description": "[%key:component::habitica::common::description_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "Cost", + "description": "Update the cost of a reward." + } + }, + "sections": { + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index e04fc58ad15..45c33a9ebb6 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -14,6 +14,7 @@ from habiticalib import ( HabiticaResponse, HabiticaScoreResponse, HabiticaSleepResponse, + HabiticaTagResponse, HabiticaTaskOrderResponse, HabiticaTaskResponse, HabiticaTasksResponse, @@ -144,6 +145,12 @@ async def mock_habiticalib() -> Generator[AsyncMock]: load_fixture("anonymized.json", DOMAIN) ) ) + client.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.create_tag.return_value = HabiticaTagResponse.from_json( + load_fixture("create_tag.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/fixtures/create_tag.json b/tests/components/habitica/fixtures/create_tag.json new file mode 100644 index 00000000000..638ec69d84e --- /dev/null +++ b/tests/components/habitica/fixtures/create_tag.json @@ -0,0 +1,8 @@ +{ + "success": true, + "data": { + "name": "Home Assistant", + "id": "8bc0afbf-ab8e-49a4-982d-67a40557ed1a" + }, + "notifications": [] +} diff --git a/tests/components/habitica/fixtures/reward.json b/tests/components/habitica/fixtures/reward.json new file mode 100644 index 00000000000..1c639c4298e --- /dev/null +++ b/tests/components/habitica/fixtures/reward.json @@ -0,0 +1,27 @@ +{ + "success": true, + "data": { + "_id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + "type": "reward", + "text": "Belohne Dich selbst", + "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", + "tags": [], + "value": 10, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "reminders": [], + "createdAt": "2024-07-07T17:51:53.266Z", + "updatedAt": "2024-07-07T17:51:53.266Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + }, + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index cf6e3864675..378652138bc 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -533,7 +533,10 @@ "type": "reward", "text": "Belohne Dich selbst", "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", - "tags": [], + "tags": [ + "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb" + ], "value": 10, "priority": 1, "attribute": "str", diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 881326f76d8..1fbc9eca595 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -1271,6 +1271,10 @@ 'th': False, 'w': True, }), + 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', + ]), 'text': 'Belohne Dich selbst', 'type': 'reward', 'value': 10.0, diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index e25ed8db313..79c9e3eab66 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1081,6 +1081,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -3321,6 +3323,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -5580,6 +5584,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -5954,6 +5960,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 5fca1884bdf..3f7ca14220b 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,16 +6,19 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, Skill +from habiticalib import Direction, HabiticaTaskResponse, Skill, Task import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( + ATTR_ALIAS, ATTR_CONFIG_ENTRY, + ATTR_COST, ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, ATTR_PRIORITY, + ATTR_REMOVE_TAG, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, @@ -33,7 +36,9 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_REWARD, ) +from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -45,7 +50,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason" RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds" @@ -889,3 +894,261 @@ async def test_get_tasks( ) assert response == snapshot + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_update_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task action exceptions.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.usefixtures("habitica") +async def test_task_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test Habitica task not found exceptions.""" + task_id = "7f902bbc-eb3d-4a8f-82cf-4e2025d69af1" + + with pytest.raises( + ServiceValidationError, + match="Unable to complete action, could not find the task '7f902bbc-eb3d-4a8f-82cf-4e2025d69af1'", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_COST: 100, + }, + Task(value=100), + ), + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_DESCRIPTION: "DESCRIPTION", + }, + Task(notes="DESCRIPTION"), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update_reward action.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +async def test_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding tags to a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Schule"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("2ac458af-0833-4f3f-bf04-98a0c33ef60b"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +async def test_create_new_tag( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding a non-existent tag and create it as new.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + habitica.create_tag.assert_awaited_with("Home Assistant") + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("8bc0afbf-ab8e-49a4-982d-67a40557ed1a"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +async def test_create_new_tag_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test create new tag exception.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.create_tag.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + +async def test_remove_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test removing tags from a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_REMOVE_TAG: ["Kreativität"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == {UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb")} From befed910da93b30b130f780dd76a74ac40da757d Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 25 Feb 2025 05:48:31 -0700 Subject: [PATCH 1216/1435] Add Re-Auth Flow to vesync (#137398) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/__init__.py | 4 +- .../components/vesync/config_flow.py | 34 +++++++++++ homeassistant/components/vesync/strings.json | 11 +++- tests/components/vesync/test_config_flow.py | 56 +++++++++++++++++++ tests/components/vesync/test_init.py | 19 ++----- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 01f88c64bf4..dddf7857545 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -59,8 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b login = await hass.async_add_executor_job(manager.login) if not login: - _LOGGER.error("Unable to login to the VeSync server") - return False + raise ConfigEntryAuthFailed hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index 07543440e91..e5537d8fcc9 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -1,5 +1,6 @@ """Config flow utilities.""" +from collections.abc import Mapping from typing import Any from pyvesync import VeSync @@ -57,3 +58,36 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN): title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password}, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication with vesync.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication with vesync.""" + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + manager = VeSync(username, password) + login = await self.hass.async_add_executor_job(manager.login) + if login: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + description_placeholders={"name": "VeSync"}, + errors={"base": "invalid_auth"}, + ) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 2232b16329b..89f401da92f 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -7,13 +7,22 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The vesync integration needs to re-authenticate your account", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py index 22a93e1ba56..38f28e73aed 100644 --- a/tests/components/vesync/test_config_flow.py +++ b/tests/components/vesync/test_config_flow.py @@ -48,3 +48,59 @@ async def test_config_flow_user_input(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_USERNAME] == "user" assert result["data"][CONF_PASSWORD] == "pass" + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a successful reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data == { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + } + + +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test an authorization error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + + with patch("pyvesync.vesync.VeSync.login", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 011545af2ae..31df2418b3d 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import Mock, patch -import pytest from pyvesync import VeSync from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry @@ -19,25 +18,17 @@ async def test_async_setup_entry__not_login( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, - caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not create config entry when not logged in.""" manager.login = Mock(return_value=False) - with ( - patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock, - patch( - "homeassistant.components.vesync.async_generate_device_list" - ) as process_mock, - ): - assert not await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() - assert setups_mock.call_count == 0 - assert process_mock.call_count == 0 + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert manager.login.call_count == 1 - assert DOMAIN not in hass.data - assert "Unable to login to the VeSync server" in caplog.text + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_async_setup_entry__no_devices( From d7301c62e2b51dd1911dee7139aa9fced8f3cb10 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Feb 2025 14:02:10 +0100 Subject: [PATCH 1217/1435] Rework the velbus configflow to make it more user-friendly (#135609) --- homeassistant/components/velbus/__init__.py | 38 +++- .../components/velbus/config_flow.py | 110 +++++++--- homeassistant/components/velbus/const.py | 1 + .../components/velbus/quality_scale.yaml | 5 +- homeassistant/components/velbus/strings.json | 26 +++ .../velbus/snapshots/test_diagnostics.ambr | 2 +- tests/components/velbus/test_config_flow.py | 203 +++++++++++------- tests/components/velbus/test_init.py | 32 ++- 8 files changed, 297 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 41b8730eeb0..35c61892964 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -135,15 +135,39 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: VelbusConfigEntry ) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/") - if config_entry.version == 1: - # This is the config entry migration for adding the new program selection + _LOGGER.error( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + # This is the config entry migration for adding the new program selection + # migrate from 1.x to 2.1 + if config_entry.version < 2: # clean the velbusCache + cache_path = hass.config.path( + STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/" + ) if os.path.isdir(cache_path): await hass.async_add_executor_job(shutil.rmtree, cache_path) - # set the new version - hass.config_entries.async_update_entry(config_entry, version=2) - _LOGGER.debug("Migration to version %s successful", config_entry.version) + # This is the config entry migration for swapping the usb unique id to the serial number + # migrate from 2.1 to 2.2 + if ( + config_entry.version < 3 + and config_entry.minor_version == 1 + and config_entry.unique_id is not None + ): + # not all velbus devices have a unique id, so handle this correctly + parts = config_entry.unique_id.split("_") + # old one should have 4 item + if len(parts) == 4: + hass.config_entries.async_update_entry(config_entry, unique_id=parts[1]) + + # update the config entry + hass.config_entries.async_update_entry(config_entry, version=2, minor_version=2) + + _LOGGER.error( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 9e99b2631d4..fc5da92588a 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -4,22 +4,23 @@ from __future__ import annotations from typing import Any +import serial.tools.list_ports import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.helpers.service_info.usb import UsbServiceInfo -from homeassistant.util import slugify -from .const import DOMAIN +from .const import CONF_TLS, DOMAIN class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the velbus config flow.""" @@ -27,14 +28,16 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device: str = "" self._title: str = "" - def _create_device(self, name: str, prt: str) -> ConfigFlowResult: + def _create_device(self) -> ConfigFlowResult: """Create an entry async.""" - return self.async_create_entry(title=name, data={CONF_PORT: prt}) + return self.async_create_entry( + title=self._title, data={CONF_PORT: self._device} + ) - async def _test_connection(self, prt: str) -> bool: + async def _test_connection(self) -> bool: """Try to connect to the velbus with the port specified.""" try: - controller = velbusaio.controller.Velbus(prt) + controller = velbusaio.controller.Velbus(self._device) await controller.connect() await controller.stop() except VelbusConnectionFailed: @@ -46,43 +49,86 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Step when user initializes a integration.""" - self._errors = {} + return self.async_show_menu( + step_id="user", menu_options=["network", "usbselect"] + ) + + async def async_step_network( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle network step.""" if user_input is not None: - name = slugify(user_input[CONF_NAME]) - prt = user_input[CONF_PORT] - self._async_abort_entries_match({CONF_PORT: prt}) - if await self._test_connection(prt): - return self._create_device(name, prt) + self._title = "Velbus Network" + if user_input[CONF_TLS]: + self._device = "tls://" + else: + self._device = "" + if user_input[CONF_PASSWORD] != "": + self._device += f"{user_input[CONF_PASSWORD]}@" + self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() + else: + user_input = { + CONF_TLS: True, + CONF_PORT: 27015, + } + + return self.async_show_form( + step_id="network", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_TLS): bool, + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + suggested_values=user_input, + ), + errors=self._errors, + ) + + async def async_step_usbselect( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle usb select step.""" + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + list_of_ports = [ + f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports + ] + + if user_input is not None: + self._title = "Velbus USB" + self._device = ports[list_of_ports.index(user_input[CONF_PORT])].device + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() else: user_input = {} - user_input[CONF_NAME] = "" user_input[CONF_PORT] = "" return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, - vol.Required(CONF_PORT, default=user_input[CONF_PORT]): str, - } + step_id="usbselect", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_PORT): vol.In(list_of_ports)}), + suggested_values=user_input, ), errors=self._errors, ) async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" - await self.async_set_unique_id( - f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" - ) - dev_path = discovery_info.device - # check if this device is not already configured - self._async_abort_entries_match({CONF_PORT: dev_path}) - # check if we can make a valid velbus connection - if not await self._test_connection(dev_path): - return self.async_abort(reason="cannot_connect") - # store the data for the config step - self._device = dev_path + await self.async_set_unique_id(discovery_info.serial_number) + self._device = discovery_info.device self._title = "Velbus USB" + self._async_abort_entries_match({CONF_PORT: self._device}) + if not await self._test_connection(): + return self.async_abort(reason="cannot_connect") # call the config step self._set_confirm_only() return await self.async_step_discovery_confirm() @@ -92,7 +138,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle Discovery confirmation.""" if user_input is not None: - return self._create_device(self._title, self._device) + return self._create_device() return self.async_show_form( step_id="discovery_confirm", diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index b40f64e8607..f42e449bdcc 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -14,6 +14,7 @@ DOMAIN: Final = "velbus" CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" +CONF_TLS: Final = "tls" SERVICE_SCAN: Final = "scan" SERVICE_SYNC: Final = "sync_clock" diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 0ad3e3ce485..829f48e6f52 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 69fc3d661e9..895f883678d 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -7,6 +7,32 @@ "name": "The name for this Velbus connection", "port": "Connection string" } + }, + "network": { + "title": "TCP/IP configuration", + "data": { + "tls": "Use TLS (secure connection)", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "tls": "Enable this if you use a secure connection to your velbus interface, like a Signum.", + "host": "The IP address or hostname of the velbus interface.", + "port": "The port number of the velbus interface.", + "password": "The password of the velbus interface, this is only needed if the interface is password protected." + }, + "description": "TCP/IP configuration, in case you use a Signum, velserv, velbus-tcp or any other velbus to TCP/IP interface." + }, + "usbselect": { + "title": "USB configuration", + "data": { + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "Select the serial port for your velbus USB interface." + }, + "description": "Select the serial port for your velbus USB interface." } }, "error": { diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr index c8bff1841e8..a280bf4c9c2 100644 --- a/tests/components/velbus/snapshots/test_diagnostics.ambr +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -10,7 +10,7 @@ 'discovery_keys': dict({ }), 'domain': 'velbus', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 04b6a51043f..ee714624b45 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,14 +7,14 @@ import pytest import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components.velbus.const import DOMAIN +from homeassistant.components.velbus.const import CONF_TLS, DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER -from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import PORT_SERIAL, PORT_TCP +from .const import PORT_SERIAL from tests.common import MockConfigEntry @@ -27,6 +27,8 @@ DISCOVERY_INFO = UsbServiceInfo( manufacturer="Velleman", ) +USB_DEV = "/dev/ttyACME100 - Some serial port, s/n: 1234 - Virtual serial port" + def com_port(): """Mock of a serial port.""" @@ -38,23 +40,15 @@ def com_port(): return port -@pytest.fixture(name="controller") -def mock_controller() -> Generator[MagicMock]: - """Mock a successful velbus controller.""" - with patch( - "homeassistant.components.velbus.config_flow.velbusaio.controller.Velbus", - autospec=True, - ) as controller: - yield controller - - @pytest.fixture(autouse=True) def override_async_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" - with patch( - "homeassistant.components.velbus.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry + with ( + patch( + "homeassistant.components.velbus.async_setup_entry", return_value=True + ) as mock, + ): + yield mock @pytest.fixture(name="controller_connection_failed") @@ -65,73 +59,126 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -async def test_user(hass: HomeAssistant) -> None: - """Test user config.""" - # simple user form +async def test_user_network_succes(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result assert result.get("flow_id") - assert result.get("type") is FlowResultType.FORM + assert result.get("type") is FlowResultType.MENU assert result.get("step_id") == "user" - - # try with a serial port - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result.get("type") is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_serial" + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "velbus:6000" + + +@pytest.mark.usefixtures("controller") +async def test_user_network_succes_tls(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result + assert result.get("flow_id") + assert result.get("type") is FlowResultType.MENU + assert result.get("step_id") == "user" + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result["type"] is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: True, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "password", + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "tls://password@velbus:6000" + + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_user_usb_succes(hass: HomeAssistant) -> None: + """Test user usb step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus USB" data = result.get("data") assert data assert data[CONF_PORT] == PORT_SERIAL - # try with a ip:port combination - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_tcp" - data = result.get("data") - assert data - assert data[CONF_PORT] == PORT_TCP - -@pytest.mark.usefixtures("controller_connection_failed") -async def test_user_fail(hass: HomeAssistant) -> None: - """Test user config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - -@pytest.mark.usefixtures("config_entry") -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("controller") +async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if Velbus is already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: "127.0.0.1:3788"}, + ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus test"}, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TLS: False, + CONF_HOST: "127.0.0.1", + CONF_PORT: 3788, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.ABORT @@ -156,7 +203,7 @@ async def test_flow_usb(hass: HomeAssistant) -> None: user_input={}, ) assert result - assert result["result"].unique_id == "0B1B:10CF_1234_Velleman_Velbus VMB1USB" + assert result["result"].unique_id == "1234" assert result.get("type") is FlowResultType.CREATE_ENTRY @@ -167,13 +214,23 @@ async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={CONF_PORT: PORT_SERIAL}, - unique_id="0B1B:10CF_1234_Velleman_Velbus VMB1USB", ) entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USB}, - data=DISCOVERY_INFO, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, ) assert result assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 3285099f2a2..2d28ba81cb1 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -16,6 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration +from .const import PORT_TCP from tests.common import MockConfigEntry @@ -107,16 +108,41 @@ async def test_migrate_config_entry( """Test successful migration of entry data.""" legacy_config = {CONF_NAME: "fake_name", CONF_PORT: "1.2.3.4:5678"} entry = MockConfigEntry(domain=DOMAIN, unique_id="my own id", data=legacy_config) - entry.add_to_hass(hass) - - assert dict(entry.data) == legacy_config assert entry.version == 1 + assert entry.minor_version == 1 + + entry.add_to_hass(hass) # test in case we do not have a cache with patch("os.path.isdir", return_value=True), patch("shutil.rmtree"): await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config assert entry.version == 2 + assert entry.minor_version == 2 + + +@pytest.mark.parametrize( + ("unique_id", "expected"), + [("vid:pid_serial_manufacturer_decription", "serial"), (None, None)], +) +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + controller: AsyncMock, + unique_id: str, + expected: str, +) -> None: + """Test the migration of unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus home"}, + unique_id=unique_id, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.unique_id == expected + assert entry.version == 2 + assert entry.minor_version == 2 async def test_api_call( From 507c0739df39529a4d77a9a44b315e084eba17c8 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 25 Feb 2025 22:14:04 +0900 Subject: [PATCH 1218/1435] Add missing ATTR_HVAC_MODE of async_set_temperature to LG ThinQ (#137621) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 56 ++++++-------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 063705f5d0d..73678e209f7 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass import logging from typing import Any @@ -10,6 +9,7 @@ from thinqconnect import DeviceType from thinqconnect.integration import ExtendedProperty from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SWING_OFF, @@ -28,31 +28,19 @@ from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator from .entity import ThinQEntity - -@dataclass(frozen=True, kw_only=True) -class ThinQClimateEntityDescription(ClimateEntityDescription): - """Describes ThinQ climate entity.""" - - min_temp: float | None = None - max_temp: float | None = None - step: float | None = None - - -DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = { +DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ClimateEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( - ThinQClimateEntityDescription( + ClimateEntityDescription( key=ExtendedProperty.CLIMATE_AIR_CONDITIONER, name=None, translation_key=ExtendedProperty.CLIMATE_AIR_CONDITIONER, ), ), DeviceType.SYSTEM_BOILER: ( - ThinQClimateEntityDescription( + ClimateEntityDescription( key=ExtendedProperty.CLIMATE_SYSTEM_BOILER, name=None, - min_temp=16, - max_temp=30, - step=1, + translation_key=ExtendedProperty.CLIMATE_SYSTEM_BOILER, ), ), } @@ -65,13 +53,7 @@ STR_TO_HVAC: dict[str, HVACMode] = { "heat": HVACMode.HEAT, } -HVAC_TO_STR: dict[HVACMode, str] = { - HVACMode.AUTO: "auto", - HVACMode.COOL: "cool", - HVACMode.DRY: "air_dry", - HVACMode.FAN_ONLY: "fan", - HVACMode.HEAT: "heat", -} +HVAC_TO_STR = {v: k for k, v in STR_TO_HVAC.items()} THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"] @@ -111,12 +93,10 @@ async def async_setup_entry( class ThinQClimateEntity(ThinQEntity, ClimateEntity): """Represent a thinq climate platform.""" - entity_description: ThinQClimateEntityDescription - def __init__( self, coordinator: DeviceDataUpdateCoordinator, - entity_description: ThinQClimateEntityDescription, + entity_description: ClimateEntityDescription, property_id: str, ) -> None: """Initialize a climate entity.""" @@ -190,18 +170,12 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_current_temperature = self.data.current_temp # Update min, max and step. - if (max_temp := self.entity_description.max_temp) is not None or ( - max_temp := self.data.max - ) is not None: - self._attr_max_temp = max_temp - if (min_temp := self.entity_description.min_temp) is not None or ( - min_temp := self.data.min - ) is not None: - self._attr_min_temp = min_temp - if (step := self.entity_description.step) is not None or ( - step := self.data.step - ) is not None: - self._attr_target_temperature_step = step + if self.data.max is not None: + self._attr_max_temp = self.data.max + if self.data.min is not None: + self._attr_min_temp = self.data.min + + self._attr_target_temperature_step = self.data.step # Update target temperatures. self._attr_target_temperature = self.data.target_temp @@ -342,6 +316,10 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.property_id, kwargs, ) + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(HVACMode(hvac_mode)) + if hvac_mode == HVACMode.OFF: + return if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: if ( From d45fce86a9d9623e1fad38fdd0f941f1f1601607 Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 13:18:12 +0000 Subject: [PATCH 1219/1435] Make Radarr units translatable (#139250) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/radarr/sensor.py | 2 -- homeassistant/components/radarr/strings.json | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index fa0cb95d549..a6d29ee9d1d 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -81,14 +81,12 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { "movie": RadarrSensorEntityDescription[int]( key="movies", translation_key="movies", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), "queue": RadarrSensorEntityDescription[int]( key="queue", translation_key="queue", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, value_fn=lambda data, _: data, diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index ec1baf6ffd8..cb624aff057 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -43,10 +43,12 @@ }, "sensor": { "movies": { - "name": "Movies" + "name": "Movies", + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" }, "start_time": { "name": "Start time" From 664e09790c7354be80710aa9b56716782168e7c3 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:22:30 +0100 Subject: [PATCH 1220/1435] Improve Minecraft Server config flow tests (#139251) --- .../minecraft_server/quality_scale.yaml | 7 +- .../minecraft_server/test_config_flow.py | 202 ++++++++++-------- 2 files changed, 108 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index a866969fc33..6cf1fc7772e 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -7,12 +7,7 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: - status: todo - comment: | - Merge test_show_config_form with full flow test. - Move full flow test to the top of all tests. - All test cases should end in either CREATE_ENTRY or ABORT. + config-flow-test-coverage: done dependency-transparency: done docs-actions: status: exempt diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 00e25028249..c57b74c6a65 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -26,8 +26,8 @@ USER_INPUT = { } -async def test_show_config_form(hass: HomeAssistant) -> None: - """Test if initial configuration form is shown.""" +async def test_full_flow_java(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Java Edition server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -35,96 +35,6 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - -async def test_service_already_configured( - hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry -) -> None: - """Test config flow abort if service is already configured.""" - bedrock_mock_config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - return_value=TEST_BEDROCK_STATUS_RESPONSE, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_address_validation_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - side_effect=ValueError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Java Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_bedrock_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Bedrock Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection to a Java Edition server.""" with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -149,8 +59,15 @@ async def test_java_connection(hass: HomeAssistant) -> None: assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION -async def test_bedrock_connection(hass: HomeAssistant) -> None: +async def test_full_flow_bedrock(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection to a Bedrock Edition server.""" + 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.minecraft_server.api.BedrockServer.lookup", @@ -171,8 +88,12 @@ async def test_bedrock_connection(hass: HomeAssistant) -> None: assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION -async def test_recovery(hass: HomeAssistant) -> None: - """Test config flow recovery (successful connection after a failed connection).""" +async def test_service_already_configured_java( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Java Edition server is already configured.""" + java_mock_config_entry.add_to_hass(hass) + with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -180,8 +101,99 @@ async def test_recovery(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_service_already_configured_bedrock( + hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Bedrock Edition server is already configured.""" + bedrock_mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_recovery_java(hass: HomeAssistant) -> None: + """Test config flow recovery with a Java Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + side_effect=OSError, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=USER_INPUT + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_ADDRESS] + assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result2["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION + + +async def test_recovery_bedrock(hass: HomeAssistant) -> None: + """Test config flow recovery with a Bedrock Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + side_effect=OSError, + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT From 7ba94a680dacf007eca5f26e5e356d9202bee543 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 25 Feb 2025 14:46:43 +0100 Subject: [PATCH 1221/1435] Revert "Bump Stookwijzer to 1.5.7" (#139253) --- homeassistant/components/stookwijzer/__init__.py | 2 ++ homeassistant/components/stookwijzer/config_flow.py | 2 ++ homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index cb198749c52..d8b9561bde9 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,6 +9,7 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,6 +44,7 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 124b0f8bfbb..32b4836763f 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,6 +27,7 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index e8f6081b9be..8243b903e8d 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.7"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1274cd99deb..e3576e8618b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.7 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e3238a5fe7..baefe19b71b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.7 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From a3bc55f49bcecb2055cff1f29f492abddf8ce37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 14:50:12 +0100 Subject: [PATCH 1222/1435] Add parallel updates to Home Connect (#139255) --- homeassistant/components/home_connect/binary_sensor.py | 2 ++ homeassistant/components/home_connect/button.py | 2 ++ homeassistant/components/home_connect/light.py | 2 ++ homeassistant/components/home_connect/number.py | 2 ++ homeassistant/components/home_connect/select.py | 2 ++ homeassistant/components/home_connect/sensor.py | 2 ++ homeassistant/components/home_connect/switch.py | 1 + homeassistant/components/home_connect/time.py | 2 ++ 8 files changed, 15 insertions(+) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 57ede4b2ff4..1f82aa71766 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -38,6 +38,8 @@ from .coordinator import ( ) from .entity import HomeConnectEntity +PARALLEL_UPDATES = 0 + REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 138979409a5..0a5538ec588 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -18,6 +18,8 @@ from .coordinator import ( from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): """Describes Home Connect button entity.""" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 9f9016855e9..72c6b9aaa2b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -36,6 +36,8 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HomeConnectLightEntityDescription(LightEntityDescription): diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 27b4bc7eb6f..404f063946c 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -30,6 +30,8 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + UNIT_MAP = { "seconds": UnitOfTime.SECONDS, "ml": UnitOfVolume.MILLILITERS, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index d5657387358..ef3e2ccbf82 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -50,6 +50,8 @@ from .coordinator import ( from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { bsh_key_to_translation_key(option): option for option in ( diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 88dd017e7d9..be0b621b508 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -27,6 +27,8 @@ from .const import ( from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity +PARALLEL_UPDATES = 0 + EVENT_OPTIONS = ["confirmed", "off", "present"] diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index d5a92eef2a4..6f9aa0e679f 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -42,6 +42,7 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 SWITCHES = ( SwitchEntityDescription( diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index 3d16dd37e21..a1761219d30 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -23,6 +23,8 @@ from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + TIME_ENTITIES = ( TimeEntityDescription( key=SettingKey.BSH_COMMON_ALARM_CLOCK, From d4dd8fd9020157971084d43a7e534196e5752852 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 14:01:45 +0000 Subject: [PATCH 1223/1435] Bump fnv-hash-fast to 1.2.6 (#139246) --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/recorder/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/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 63254384666..f9a31489ca4 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 6f555704670..40513c8ea24 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.38", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 335a3b1da29..6bcac95366d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 dbus-fast==2.33.0 -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.0 diff --git a/pyproject.toml b/pyproject.toml index 1224cc0c70e..7a970b405a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.92.0", diff --git a/requirements.txt b/requirements.txt index 1ec004d7f65..f002f0d6ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 hass-nabucasa==0.92.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index e3576e8618b..dcb11cf69c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,7 +946,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baefe19b71b..04c2d8eb789 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -805,7 +805,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 From b8b153b87f801269076300a58d768e885f40242d Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Tue, 25 Feb 2025 06:07:42 -0800 Subject: [PATCH 1224/1435] Make default dim level configurable in Lutron (#137127) --- .../components/lutron/config_flow.py | 48 ++++++++++++++++++- homeassistant/components/lutron/const.py | 4 ++ homeassistant/components/lutron/light.py | 20 ++++++-- homeassistant/components/lutron/strings.json | 9 ++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index 6a48e0d4b67..3f55a2b131b 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -9,10 +9,21 @@ from urllib.error import HTTPError from pylutron import Lutron import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) -from .const import DOMAIN +from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -68,3 +79,36 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(OptionsFlow): + """Handle option flow for lutron.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_DEFAULT_DIMMER_LEVEL, + default=self.config_entry.options.get( + CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL + ), + ): NumberSelector( + NumberSelectorConfig(min=1, max=255, mode=NumberSelectorMode.SLIDER) + ) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/lutron/const.py b/homeassistant/components/lutron/const.py index 3862f7eb1d8..b69e35f38ba 100644 --- a/homeassistant/components/lutron/const.py +++ b/homeassistant/components/lutron/const.py @@ -1,3 +1,7 @@ """Lutron constants.""" DOMAIN = "lutron" + +CONF_DEFAULT_DIMMER_LEVEL = "default_dimmer_level" + +DEFAULT_DIMMER_LEVEL = 255 / 2 diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 58183fb0a38..a7489e13b7b 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pylutron import Output +from pylutron import Lutron, LutronEntity, Output from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData +from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL from .entity import LutronDevice @@ -37,7 +38,7 @@ async def async_setup_entry( async_add_entities( ( - LutronLight(area_name, device, entry_data.client) + LutronLight(area_name, device, entry_data.client, config_entry) for area_name, device in entry_data.lights ), True, @@ -64,6 +65,17 @@ class LutronLight(LutronDevice, LightEntity): _prev_brightness: int | None = None _attr_name = None + def __init__( + self, + area_name: str, + lutron_device: LutronEntity, + controller: Lutron, + config_entry: ConfigEntry, + ) -> None: + """Initialize the device.""" + super().__init__(area_name, lutron_device, controller) + self._config_entry = config_entry + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if flash := kwargs.get(ATTR_FLASH): @@ -72,7 +84,9 @@ class LutronLight(LutronDevice, LightEntity): if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable: brightness = kwargs[ATTR_BRIGHTNESS] elif self._prev_brightness == 0: - brightness = 255 / 2 + brightness = self._config_entry.options.get( + CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL + ) else: brightness = self._prev_brightness self._prev_brightness = brightness diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json index b73e0bd15ed..37db509e294 100644 --- a/homeassistant/components/lutron/strings.json +++ b/homeassistant/components/lutron/strings.json @@ -19,6 +19,15 @@ } } }, + "options": { + "step": { + "init": { + "data": { + "default_dimmer_level": "Default light level when first turning on a light from Home Assistant" + } + } + } + }, "entity": { "event": { "button": { From b9dbf07a5e7e00a8e04c3b5c683ad11621f1658b Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:09:58 +0100 Subject: [PATCH 1225/1435] Set PARALLEL_UPDATES in all Minecraft Server platforms (#139259) --- homeassistant/components/minecraft_server/binary_sensor.py | 3 +++ .../components/minecraft_server/quality_scale.yaml | 6 +----- homeassistant/components/minecraft_server/sensor.py | 3 +++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 39e12228451..a7279040a6d 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -22,6 +22,9 @@ BINARY_SENSOR_DESCRIPTIONS = [ ), ] +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index 6cf1fc7772e..61a975632bb 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -51,11 +51,7 @@ rules: log-when-unavailable: status: done comment: Handled by coordinator. - parallel-updates: - status: todo - comment: | - Although this is handled by the coordinator and no service actions are provided, - PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule. + parallel-updates: done reauthentication-flow: status: exempt comment: No authentication is required for the integration. diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 6effa53fbf2..cfc16c7724d 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -30,6 +30,9 @@ KEY_VERSION = "version" UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_ONLINE = "players" +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class MinecraftServerSensorEntityDescription(SensorEntityDescription): From 75660469956aaf7c9039f4aaacfbc0d1e28c2677 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 25 Feb 2025 16:10:03 +0200 Subject: [PATCH 1226/1435] Bump aiowebostv to 0.7.1 (#139244) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 45c9628539c..06cbca32453 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.0"], + "requirements": ["aiowebostv==0.7.1"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index dcb11cf69c7..f7b9f2425a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.0 # homeassistant.components.webostv -aiowebostv==0.7.0 +aiowebostv==0.7.1 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04c2d8eb789..90ea8c808c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.0 # homeassistant.components.webostv -aiowebostv==0.7.0 +aiowebostv==0.7.1 # homeassistant.components.withings aiowithings==3.1.6 From 923ec71bf673582508128bcccb409b91b0453de0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 15:10:21 +0100 Subject: [PATCH 1227/1435] Consistently capitalize "Velbus" brand name, camel-case "VelServ" (#139257) --- homeassistant/components/velbus/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 895f883678d..a50395af115 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -17,12 +17,12 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "tls": "Enable this if you use a secure connection to your velbus interface, like a Signum.", - "host": "The IP address or hostname of the velbus interface.", - "port": "The port number of the velbus interface.", - "password": "The password of the velbus interface, this is only needed if the interface is password protected." + "tls": "Enable this if you use a secure connection to your Velbus interface, like a Signum.", + "host": "The IP address or hostname of the Velbus interface.", + "port": "The port number of the Velbus interface.", + "password": "The password of the Velbus interface, this is only needed if the interface is password protected." }, - "description": "TCP/IP configuration, in case you use a Signum, velserv, velbus-tcp or any other velbus to TCP/IP interface." + "description": "TCP/IP configuration, in case you use a Signum, VelServ, velbus-tcp or any other Velbus to TCP/IP interface." }, "usbselect": { "title": "USB configuration", @@ -30,9 +30,9 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "port": "Select the serial port for your velbus USB interface." + "port": "Select the serial port for your Velbus USB interface." }, - "description": "Select the serial port for your velbus USB interface." + "description": "Select the serial port for your Velbus USB interface." } }, "error": { From 1633700a5811f7fb9219976255cc3e4306a4c637 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 14:25:07 +0000 Subject: [PATCH 1228/1435] Bump cached-ipaddress to 0.9.2 (#139245) --- 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 65d43f80abe..5b3a5abd26f 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -16,6 +16,6 @@ "requirements": [ "aiodhcpwatcher==1.1.1", "aiodiscover==2.6.1", - "cached-ipaddress==0.8.1" + "cached-ipaddress==0.9.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6bcac95366d..e4f9466a10e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 bluetooth-data-tools==1.23.4 -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 diff --git a/requirements_all.txt b/requirements_all.txt index f7b9f2425a6..5e6841ecf1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90ea8c808c4..46ce49503be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -597,7 +597,7 @@ bthome-ble==3.12.4 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 From 1f93d2cefb3cc2cde56fb7be25cd78aa3aa8f5cb Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 14:26:22 +0000 Subject: [PATCH 1229/1435] Make Sonarr component's units translatable (#139254) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonarr/sensor.py | 5 ----- homeassistant/components/sonarr/strings.json | 15 ++++++++++----- tests/components/sonarr/test_sensor.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 6a0293e455c..983ac76d93e 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -90,7 +90,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "commands": SonarrSensorEntityDescription[list[Command]]( key="commands", translation_key="commands", - native_unit_of_measurement="Commands", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: {c.name: c.status for c in data}, @@ -107,7 +106,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "queue": SonarrSensorEntityDescription[SonarrQueue]( key="queue", translation_key="queue", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_queue_attr, @@ -115,7 +113,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "series": SonarrSensorEntityDescription[list[SonarrSeries]]( key="series", translation_key="series", - native_unit_of_measurement="Series", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: { @@ -129,7 +126,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "upcoming": SonarrSensorEntityDescription[list[SonarrCalendar]]( key="upcoming", translation_key="upcoming", - native_unit_of_measurement="Episodes", value_fn=len, attributes_fn=lambda data: { e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data @@ -138,7 +134,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( key="wanted", translation_key="wanted", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_wanted_attr, diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 5b17f3283e8..940ec650270 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -37,22 +37,27 @@ "entity": { "sensor": { "commands": { - "name": "Commands" + "name": "Commands", + "unit_of_measurement": "commands" }, "diskspace": { "name": "Disk space" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "episodes" }, "series": { - "name": "Shows" + "name": "Shows", + "unit_of_measurement": "series" }, "upcoming": { - "name": "Upcoming" + "name": "Upcoming", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" }, "wanted": { - "name": "Wanted" + "name": "Wanted", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" } } } diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 3ccff4c88ba..78f03e8b6de 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -49,7 +49,7 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_commands") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Commands" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "commands" assert state.state == "2" state = hass.states.get("sensor.sonarr_disk_space") @@ -60,25 +60,25 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_queue") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("The Andy Griffith Show S01E01") == "100.00%" assert state.state == "1" state = hass.states.get("sensor.sonarr_shows") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Series" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "series" assert state.attributes.get("The Andy Griffith Show") == "0/0 Episodes" assert state.state == "1" state = hass.states.get("sensor.sonarr_upcoming") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers") == "S04E11" assert state.state == "1" state = hass.states.get("sensor.sonarr_wanted") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-26T17:30:00-08:00" assert ( state.attributes.get("The Andy Griffith Show S01E01") From 776501f5e65789d5ff20e154f01542411f01cdff Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:41:36 +0100 Subject: [PATCH 1230/1435] Bump stookwijzer to 1.5.8 (#139258) --- homeassistant/components/stookwijzer/__init__.py | 2 -- homeassistant/components/stookwijzer/config_flow.py | 2 -- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index d8b9561bde9..cb198749c52 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -44,7 +43,6 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 32b4836763f..124b0f8bfbb 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -27,7 +26,6 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 8243b903e8d..86fccf64db1 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.4"] + "requirements": ["stookwijzer==1.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e6841ecf1e..55b4d140321 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.8 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46ce49503be..072250cad20 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.8 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 2b55f3af3677b2a3c5d98b38f08d8447879fbd37 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Feb 2025 15:42:12 +0100 Subject: [PATCH 1231/1435] Bump Velbus to bronze quality scale (#139256) --- homeassistant/components/velbus/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 960f127d16e..29504277651 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,6 +13,7 @@ "velbus-packet", "velbus-protocol" ], + "quality_scale": "bronze", "requirements": ["velbus-aio==2025.1.1"], "usb": [ { diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 195dd93e630..d155cc74acb 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2167,7 +2167,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "velux", "venstar", "vera", - "velbus", "verisure", "versasense", "version", From 3059d069600cad10676bff47df0e718964e1bc66 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 25 Feb 2025 15:49:12 +0100 Subject: [PATCH 1232/1435] Add Homee number platform (#138962) Co-authored-by: Joostlek --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/button.py | 2 +- homeassistant/components/homee/cover.py | 22 +- homeassistant/components/homee/entity.py | 6 +- homeassistant/components/homee/light.py | 12 +- homeassistant/components/homee/number.py | 130 +++ homeassistant/components/homee/strings.json | 44 + homeassistant/components/homee/switch.py | 4 +- homeassistant/components/homee/valve.py | 2 +- tests/components/homee/fixtures/numbers.json | 337 ++++++++ .../homee/snapshots/test_number.ambr | 802 ++++++++++++++++++ tests/components/homee/test_number.py | 74 ++ 12 files changed, 1414 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/homee/number.py create mode 100644 tests/components/homee/fixtures/numbers.json create mode 100644 tests/components/homee/snapshots/test_number.ambr create mode 100644 tests/components/homee/test_number.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index c576fa6d23c..d7785ad9104 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.COVER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py index f39ee3f5a87..af6d769c1dc 100644 --- a/homeassistant/components/homee/button.py +++ b/homeassistant/components/homee/button.py @@ -75,4 +75,4 @@ class HomeeButton(HomeeEntity, ButtonEntity): async def async_press(self) -> None: """Handle the button press.""" - await self.async_set_value(1) + await self.async_set_homee_value(1) diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index a3695f7ade6..6e7e4fd5c55 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -205,17 +205,17 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): """Open the cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_value(self._open_close_attribute, 0) + await self.async_set_homee_value(self._open_close_attribute, 0) else: - await self.async_set_value(self._open_close_attribute, 1) + await self.async_set_homee_value(self._open_close_attribute, 1) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_value(self._open_close_attribute, 1) + await self.async_set_homee_value(self._open_close_attribute, 1) else: - await self.async_set_value(self._open_close_attribute, 0) + await self.async_set_homee_value(self._open_close_attribute, 0) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -230,12 +230,12 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_homee_value(attribute, homee_position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" if self._open_close_attribute is not None: - await self.async_set_value(self._open_close_attribute, 2) + await self.async_set_homee_value(self._open_close_attribute, 2) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -245,9 +245,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, 2) else: - await self.async_set_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, 1) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -257,9 +257,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, 1) else: - await self.async_set_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, 2) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" @@ -276,4 +276,4 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_homee_value(attribute, homee_position) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 5a7f34b1c37..165a655d82b 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -54,7 +54,7 @@ class HomeeEntity(Entity): """Return the availability of the underlying node.""" return (self._attribute.state == AttributeState.NORMAL) and self._host_connected - async def async_set_value(self, value: float) -> None: + async def async_set_homee_value(self, value: float) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data try: @@ -144,7 +144,9 @@ class HomeeNodeEntity(Entity): return None - async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: + async def async_set_homee_value( + self, attribute: HomeeAttribute, value: float + ) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data try: diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index 12d127c0945..b9c4460075a 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -175,24 +175,26 @@ class HomeeLight(HomeeNodeEntity, LightEntity): kwargs[ATTR_BRIGHTNESS], ) ) - await self.async_set_value(self._dimmer_attr, target_value) + await self.async_set_homee_value(self._dimmer_attr, target_value) else: # If no brightness value is given, just turn on. - await self.async_set_value(self._on_off_attr, 1) + await self.async_set_homee_value(self._on_off_attr, 1) if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None: - await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]) + await self.async_set_homee_value( + self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if ATTR_HS_COLOR in kwargs: color = kwargs[ATTR_HS_COLOR] if self._col_attr is not None: - await self.async_set_value( + await self.async_set_homee_value( self._col_attr, rgb_list_to_decimal(color_hs_to_RGB(*color)), ) async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - await self.async_set_value(self._on_off_attr, 0) + await self.async_set_homee_value(self._on_off_attr, 0) def _get_supported_color_modes(self) -> set[ColorMode]: """Determine the supported color modes from the available attributes.""" diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py new file mode 100644 index 00000000000..3f1f08a6618 --- /dev/null +++ b/homeassistant/components/homee/number.py @@ -0,0 +1,130 @@ +"""The Homee number platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import HOMEE_UNIT_TO_HA_UNIT +from .entity import HomeeEntity + +NUMBER_DESCRIPTIONS = { + AttributeType.DOWN_POSITION: NumberEntityDescription( + key="down_position", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription( + key="down_slat_position", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.DOWN_TIME: NumberEntityDescription( + key="down_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription( + key="endposition_configuration", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription( + key="motion_alarm_cancelation_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription( + key="open_window_detection_sensibility", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.POLLING_INTERVAL: NumberEntityDescription( + key="polling_interval", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription( + key="shutter_slat_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription( + key="slat_max_angle", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription( + key="slat_min_angle", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_STEPS: NumberEntityDescription( + key="slat_steps", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription( + key="temperature_offset", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.UP_TIME: NumberEntityDescription( + key="up_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription( + key="wake_up_interval", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the number component.""" + + async_add_entities( + HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value" + ) + + +class HomeeNumber(HomeeEntity, NumberEntity): + """Representation of a Homee number.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: NumberEntityDescription, + ) -> None: + """Initialize a Homee number entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit] + self._attr_native_min_value = attribute.minimum + self._attr_native_max_value = attribute.maximum + self._attr_native_step = attribute.step_value + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + return super().available and self._attribute.editable + + @property + def native_value(self) -> int: + """Return the native value of the number.""" + return int(self._attribute.current_value) + + async def async_set_native_value(self, value: float) -> None: + """Set the selected value.""" + await self.async_set_homee_value(value) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index a78e12341a3..cf5b90dbe2a 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -66,6 +66,50 @@ "name": "Light {instance}" } }, + "number": { + "down_position": { + "name": "Down position" + }, + "down_slat_position": { + "name": "Down slat position" + }, + "down_time": { + "name": "Down-movement duration" + }, + "endposition_configuration": { + "name": "End position" + }, + "motion_alarm_cancelation_delay": { + "name": "Motion alarm delay" + }, + "open_window_detection_sensibility": { + "name": "Window open sensibility" + }, + "polling_interval": { + "name": "Polling interval" + }, + "shutter_slat_time": { + "name": "Slat turn duration" + }, + "slat_max_angle": { + "name": "Maximum slat angle" + }, + "slat_min_angle": { + "name": "Minimum slat angle" + }, + "slat_steps": { + "name": "Slat steps" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "up_time": { + "name": "Up-movement duration" + }, + "wake_up_interval": { + "name": "Wake-up interval" + } + }, "sensor": { "brightness_instance": { "name": "Illuminance {instance}" diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index e8b87b2b8e0..86c7acdbf11 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -120,8 +120,8 @@ class HomeeSwitch(HomeeEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.async_set_value(1) + await self.async_set_homee_value(1) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.async_set_value(0) + await self.async_set_homee_value(0) diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py index b54d6334263..9a4ff446a10 100644 --- a/homeassistant/components/homee/valve.py +++ b/homeassistant/components/homee/valve.py @@ -78,4 +78,4 @@ class HomeeValve(HomeeEntity, ValveEntity): async def async_set_valve_position(self, position: int) -> None: """Move the valve to a specific position.""" - await self.async_set_value(position) + await self.async_set_homee_value(position) diff --git a/tests/components/homee/fixtures/numbers.json b/tests/components/homee/fixtures/numbers.json new file mode 100644 index 00000000000..c8773a89568 --- /dev/null +++ b/tests/components/homee/fixtures/numbers.json @@ -0,0 +1,337 @@ +{ + "id": 1, + "name": "Test Number", + "profile": 2011, + "image": "default", + "favorite": 0, + "order": 1, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1731020474, + "added": 1680027411, + "history": 1, + "cube_type": 3, + "note": "", + "services": 0, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 0.5, + "editable": 1, + "type": 349, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": -75, + "maximum": 75, + "current_value": 38.0, + "target_value": 38.0, + "last_value": 38.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 350, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 111, + "state": 1, + "last_changed": 1615396252, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 130, + "current_value": 129.0, + "target_value": 129.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 325, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 15300, + "current_value": 10.0, + "target_value": 1.0, + "last_value": 10.0, + "unit": "s", + "step_value": 1.0, + "editable": 0, + "type": 28, + "state": 1, + "last_changed": 1676204559, + "changed_by": 0, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 3, + "current_value": 3.0, + "target_value": 3.0, + "last_value": 2.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 261, + "state": 1, + "last_changed": 1666336770, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 5, + "maximum": 45, + "current_value": 30.0, + "target_value": 30.0, + "last_value": 0.0, + "unit": "min", + "step_value": 5.0, + "editable": 1, + "type": 88, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 24, + "current_value": 1.6, + "target_value": 1.6, + "last_value": 0.0, + "unit": "s", + "step_value": 0.1, + "editable": 1, + "type": 114, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": 75.0, + "target_value": 75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 323, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": -75.0, + "target_value": -75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 322, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 20, + "current_value": 6.0, + "target_value": 6.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 174, + "state": 1, + "last_changed": 1672149083, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": -5, + "maximum": 128, + "current_value": -3, + "target_value": -3, + "last_value": 128.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 64, + "state": 6, + "last_changed": 1711799534, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 110, + "state": 1, + "last_changed": 1615396246, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 30, + "maximum": 7200, + "current_value": 600.0, + "target_value": 600.0, + "last_value": 600.0, + "unit": "min", + "step_value": 30.0, + "editable": 1, + "type": 29, + "state": 1, + "last_changed": 1739333970, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 240, + "current_value": 12.0, + "target_value": 12.0, + "last_value": 12.0, + "unit": "h", + "step_value": 1.0, + "editable": 0, + "type": 29, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "fixed_value", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr new file mode 100644 index 00000000000..04b1aefab00 --- /dev/null +++ b/tests/components/homee/snapshots/test_number.ambr @@ -0,0 +1,802 @@ +# serializer version: 1 +# name: test_number_snapshot[number.test_number_down_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_movement_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': 'Down-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_time', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_down_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Down-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_down_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_position', + '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': 'Down position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_position', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down position', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_number_down_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_slat_position', + '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': 'Down slat position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_slat_position', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down slat position', + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_down_slat_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_end_position', + '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': 'End position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'endposition_configuration', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number End position', + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_end_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '129', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_maximum_slat_angle', + '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': 'Maximum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_max_angle', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Maximum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_maximum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_minimum_slat_angle', + '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': 'Minimum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_min_angle', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Minimum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_minimum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-75', + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion alarm delay', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm_cancelation_delay', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Motion alarm delay', + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_polling_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Polling interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'polling_interval', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Polling interval', + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_polling_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_steps', + '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': 'Slat steps', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_steps', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Slat steps', + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_slat_steps', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_turn_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': 'Slat turn duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'shutter_slat_time', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Slat turn duration', + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_slat_turn_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_temperature_offset', + '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': 'Temperature offset', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Temperature offset', + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_up_movement_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': 'Up-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_time', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Up-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_up_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_wake_up_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wake-up interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake_up_interval', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Wake-up interval', + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_wake_up_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '600', + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_window_open_sensibility', + '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': 'Window open sensibility', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'open_window_detection_sensibility', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Window open sensibility', + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_window_open_sensibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py new file mode 100644 index 00000000000..73ca707c2d5 --- /dev/null +++ b/tests/components/homee/test_number.py @@ -0,0 +1,74 @@ +"""Test Homee nmumbers.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_set_value( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value service.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_down_position", ATTR_VALUE: 90}, + blocking=True, + ) + number = mock_homee.nodes[0].attributes[0] + mock_homee.set_value.assert_called_once_with(number.node_id, number.id, 90) + + +async def test_set_value_not_editable( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value if attribute is not editable.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_motion_alarm_delay", ATTR_VALUE: 10000}, + blocking=True, + ) + assert not mock_homee.set_value.called + assert not hass.states.async_available("number.test_number_motion_alarm_delay") + + +async def test_number_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From e99bf21a36d58da9024960159b99bfc80fe1b861 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 25 Feb 2025 22:51:21 +0800 Subject: [PATCH 1233/1435] Fix yolink lock v2 state update (#138710) --- homeassistant/components/yolink/lock.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py index 135d0e26d04..5e244dd08f2 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -51,15 +51,16 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" state_value = state.get("state") - if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: - self._attr_is_locked = ( - state_value["lock"] == "locked" if state_value is not None else None - ) - else: - self._attr_is_locked = ( - state_value == "locked" if state_value is not None else None - ) - self.async_write_ha_state() + if state_value is not None: + if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: + self._attr_is_locked = ( + state_value["lock"] == "locked" if state_value is not None else None + ) + else: + self._attr_is_locked = ( + state_value == "locked" if state_value is not None else None + ) + self.async_write_ha_state() async def call_lock_state_change(self, state: str) -> None: """Call setState api to change lock state.""" @@ -69,7 +70,7 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): ) else: await self.call_device(ClientRequest("setState", {"state": state})) - self._attr_is_locked = state == "lock" + self._attr_is_locked = state in ["locked", "lock"] self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: From f96e31fad851d8ab61f75695ff83ba0ea0f5092f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:51:43 +0100 Subject: [PATCH 1234/1435] Set Minecraft Server quality scale to silver (#139265) --- homeassistant/components/minecraft_server/manifest.json | 1 + homeassistant/components/minecraft_server/quality_scale.yaml | 2 +- script/hassfest/quality_scale.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index d6ade4853c9..be399a3c8dc 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], + "quality_scale": "silver", "requirements": ["mcstatus==11.1.1"] } diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index 61a975632bb..288e58fad39 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -14,7 +14,7 @@ rules: comment: Integration doesn't provide any service actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: Handled by coordinator. diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d155cc74acb..5f90fff81d5 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1722,7 +1722,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "mikrotik", "mill", "min_max", - "minecraft_server", "minio", "mjpeg", "moat", From 1fb51ef1891555fa864ef23b7286dc53920c9abe Mon Sep 17 00:00:00 2001 From: Andrew <34544450+10100011@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:54:10 +0000 Subject: [PATCH 1235/1435] Add OpenWeatherMap Minute forecast action (#128799) --- .../components/openweathermap/const.py | 1 + .../components/openweathermap/coordinator.py | 24 ++++ .../components/openweathermap/icons.json | 7 + .../components/openweathermap/services.yaml | 5 + .../components/openweathermap/strings.json | 11 ++ .../components/openweathermap/weather.py | 27 +++- .../snapshots/test_weather.ambr | 25 ++++ .../openweathermap/test_config_flow.py | 30 +++-- .../components/openweathermap/test_weather.py | 121 ++++++++++++++++++ 9 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/openweathermap/icons.json create mode 100644 homeassistant/components/openweathermap/services.yaml create mode 100644 tests/components/openweathermap/snapshots/test_weather.ambr create mode 100644 tests/components/openweathermap/test_weather.py diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 81a6544c7ce..de317709f5b 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -48,6 +48,7 @@ ATTR_API_WEATHER_CODE = "weather_code" ATTR_API_CLOUD_COVERAGE = "cloud_coverage" ATTR_API_FORECAST = "forecast" ATTR_API_CURRENT = "current" +ATTR_API_MINUTE_FORECAST = "minute_forecast" ATTR_API_HOURLY_FORECAST = "hourly_forecast" ATTR_API_DAILY_FORECAST = "daily_forecast" UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 55c1aa469c2..994949b5e03 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -10,6 +10,7 @@ from pyopenweathermap import ( CurrentWeather, DailyWeatherForecast, HourlyWeatherForecast, + MinutelyWeatherForecast, OWMClient, RequestError, WeatherReport, @@ -34,10 +35,14 @@ from .const import ( ATTR_API_CONDITION, ATTR_API_CURRENT, ATTR_API_DAILY_FORECAST, + ATTR_API_DATETIME, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, + ATTR_API_PRECIPITATION, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, ATTR_API_RAIN, @@ -106,6 +111,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return { ATTR_API_CURRENT: current_weather, + ATTR_API_MINUTE_FORECAST: ( + self._get_minute_weather_data(weather_report.minutely_forecast) + if weather_report.minutely_forecast is not None + else {} + ), ATTR_API_HOURLY_FORECAST: [ self._get_hourly_forecast_weather_data(item) for item in weather_report.hourly_forecast @@ -116,6 +126,20 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ], } + def _get_minute_weather_data( + self, minute_forecast: list[MinutelyWeatherForecast] + ) -> dict: + """Get minute weather data from the forecast.""" + return { + ATTR_API_FORECAST: [ + { + ATTR_API_DATETIME: item.date_time, + ATTR_API_PRECIPITATION: round(item.precipitation, 2), + } + for item in minute_forecast + ] + } + def _get_current_weather_data(self, current_weather: CurrentWeather): return { ATTR_API_CONDITION: self._get_condition(current_weather.condition.id), diff --git a/homeassistant/components/openweathermap/icons.json b/homeassistant/components/openweathermap/icons.json new file mode 100644 index 00000000000..d493b1538ba --- /dev/null +++ b/homeassistant/components/openweathermap/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_minute_forecast": { + "service": "mdi:weather-snowy-rainy" + } + } +} diff --git a/homeassistant/components/openweathermap/services.yaml b/homeassistant/components/openweathermap/services.yaml new file mode 100644 index 00000000000..6bbcf1b23e4 --- /dev/null +++ b/homeassistant/components/openweathermap/services.yaml @@ -0,0 +1,5 @@ +get_minute_forecast: + target: + entity: + domain: weather + integration: openweathermap diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 46b5feab75c..0692087bc23 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -47,5 +47,16 @@ } } } + }, + "services": { + "get_minute_forecast": { + "name": "Get minute forecast", + "description": "Get minute weather forecast." + } + }, + "exceptions": { + "service_minute_forecast_mode": { + "message": "Minute forecast is available only when {name} mode is set to v3.0" + } } } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 43e9c0a868a..a6ad163e1c8 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -14,7 +14,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,6 +30,7 @@ from .const import ( ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_VISIBILITY_DISTANCE, @@ -44,6 +47,8 @@ from .const import ( ) from .coordinator import WeatherUpdateCoordinator +SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast" + async def async_setup_entry( hass: HomeAssistant, @@ -61,6 +66,14 @@ async def async_setup_entry( async_add_entities([owm_weather], False) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + name=SERVICE_GET_MINUTE_FORECAST, + schema=None, + func="async_get_minute_forecast", + supports_response=SupportsResponse.ONLY, + ) + class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an OpenWeatherMap sensor.""" @@ -91,6 +104,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) + self.mode = mode if mode in (OWM_MODE_V30, OWM_MODE_V25): self._attr_supported_features = ( @@ -100,6 +114,17 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina elif mode == OWM_MODE_FREE_FORECAST: self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY + async def async_get_minute_forecast(self) -> dict[str, list[dict]] | dict: + """Return Minute forecast.""" + + if self.mode == OWM_MODE_V30: + return self.coordinator.data[ATTR_API_MINUTE_FORECAST] + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_minute_forecast_mode", + translation_placeholders={"name": DEFAULT_NAME}, + ) + @property def condition(self) -> str | None: """Return the current condition.""" diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr new file mode 100644 index 00000000000..c89dcb96a9c --- /dev/null +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_get_minute_forecast[mock_service_response] + dict({ + 'weather.openweathermap': dict({ + 'forecast': list([ + dict({ + 'datetime': 1728672360, + 'precipitation': 0, + }), + dict({ + 'datetime': 1728672420, + 'precipitation': 1.23, + }), + dict({ + 'datetime': 1728672480, + 'precipitation': 4.5, + }), + dict({ + 'datetime': 1728672540, + 'precipitation': 0, + }), + ]), + }), + }) +# --- diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index aec34360754..d5e01677dd8 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -18,7 +18,7 @@ from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DEFAULT_OWM_MODE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_V30, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -40,13 +40,15 @@ CONFIG = { CONF_LATITUDE: 50, CONF_LONGITUDE: 40, CONF_LANGUAGE: DEFAULT_LANGUAGE, - CONF_MODE: OWM_MODE_V25, + CONF_MODE: OWM_MODE_V30, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -def _create_mocked_owm_factory(is_valid: bool): +def _create_static_weather_report() -> WeatherReport: + """Create a static WeatherReport.""" + current_weather = CurrentWeather( date_time=datetime.fromtimestamp(1714063536, tz=UTC), temperature=6.84, @@ -60,8 +62,8 @@ def _create_mocked_owm_factory(is_valid: bool): wind_speed=9.83, wind_bearing=199, wind_gust=None, - rain={}, - snow={}, + rain={"1h": 1.21}, + snow=None, condition=WeatherCondition( id=803, main="Clouds", @@ -106,13 +108,21 @@ def _create_mocked_owm_factory(is_valid: bool): rain=0, snow=0, ) - minutely_weather_forecast = MinutelyWeatherForecast( - date_time=1728672360, precipitation=2.54 - ) - weather_report = WeatherReport( - current_weather, [minutely_weather_forecast], [], [daily_weather_forecast] + minutely_weather_forecast = [ + MinutelyWeatherForecast(date_time=1728672360, precipitation=0), + MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), + MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), + MinutelyWeatherForecast(date_time=1728672540, precipitation=0), + ] + return WeatherReport( + current_weather, minutely_weather_forecast, [], [daily_weather_forecast] ) + +def _create_mocked_owm_factory(is_valid: bool): + """Create a mocked OWM client.""" + + weather_report = _create_static_weather_report() mocked_owm_client = MagicMock() mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py new file mode 100644 index 00000000000..5d3565d6ca9 --- /dev/null +++ b/tests/components/openweathermap/test_weather.py @@ -0,0 +1,121 @@ +"""Test the OpenWeatherMap weather entity.""" + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.openweathermap.const import ( + DEFAULT_LANGUAGE, + DOMAIN, + OWM_MODE_V25, + OWM_MODE_V30, +) +from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .test_config_flow import _create_static_weather_report + +from tests.common import AsyncMock, MockConfigEntry, patch + +ENTITY_ID = "weather.openweathermap" +API_KEY = "test_api_key" +LATITUDE = 12.34 +LONGITUDE = 56.78 +NAME = "openweathermap" + +# Define test data for mocked weather report +static_weather_report = _create_static_weather_report() + + +def mock_config_entry(mode: str) -> MockConfigEntry: + """Create a mock OpenWeatherMap config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, + CONF_NAME: NAME, + }, + options={CONF_MODE: mode, CONF_LANGUAGE: DEFAULT_LANGUAGE}, + version=5, + ) + + +@pytest.fixture +def mock_config_entry_v25() -> MockConfigEntry: + """Create a mock OpenWeatherMap v2.5 config entry.""" + return mock_config_entry(OWM_MODE_V25) + + +@pytest.fixture +def mock_config_entry_v30() -> MockConfigEntry: + """Create a mock OpenWeatherMap v3.0 config entry.""" + return mock_config_entry(OWM_MODE_V30) + + +async def setup_mock_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +): + """Set up the MockConfigEntry and assert it is loaded correctly.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID) + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@patch( + "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_get_minute_forecast( + hass: HomeAssistant, + mock_config_entry_v30: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_minute_forecast Service call.""" + await setup_mock_config_entry(hass, mock_config_entry_v30) + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert result == snapshot(name="mock_service_response") + + +@patch( + "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_mode_fail( + hass: HomeAssistant, + mock_config_entry_v25: MockConfigEntry, +) -> None: + """Test that Minute forecasting fails when mode is not v3.0.""" + await setup_mock_config_entry(hass, mock_config_entry_v25) + + # Expect a ServiceValidationError when mode is not OWM_MODE_V30 + with pytest.raises( + ServiceValidationError, + match="Minute forecast is available only when OpenWeatherMap mode is set to v3.0", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) From 47e78e9008d6b0b4c880d21376009f31f309c213 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:55:31 +0200 Subject: [PATCH 1236/1435] Fix Ezviz entity state for cameras that are offline (#136003) --- homeassistant/components/ezviz/camera.py | 5 ----- homeassistant/components/ezviz/entity.py | 10 ++++++++++ homeassistant/components/ezviz/image.py | 6 ++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 54879fd6a9b..e3d01bef83e 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -141,11 +141,6 @@ class EzvizCamera(EzvizEntity, Camera): if camera_password: self._attr_supported_features = CameraEntityFeature.STREAM - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.data["status"] != 2 - @property def is_on(self) -> bool: """Return true if on.""" diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 44de4a0c9c7..54614e4899a 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -42,6 +42,11 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.data["status"] != 2 + class EzvizBaseEntity(Entity): """Generic entity for EZVIZ individual poll entities.""" @@ -72,3 +77,8 @@ class EzvizBaseEntity(Entity): def data(self) -> dict[str, Any]: """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.data["status"] != 2 diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index f335406a367..ea032a8ec00 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging +from propcache import cached_property from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image @@ -62,6 +63,11 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): else None ) + @cached_property + def available(self) -> bool: + """Entity gets data from ezviz API so always available.""" + return True + async def _async_load_image_from_url(self, url: str) -> Image | None: """Load an image by url.""" if response := await self._fetch_url(url): From 72502c1a151e0268abfb3363d4385f76ac3adc06 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 16:09:15 +0100 Subject: [PATCH 1237/1435] Use proper camel-case for "VeSync", fix sentence-casing in title (#139252) Just a quick follow-up PR to fix these two spelling mistakes. --- homeassistant/components/vesync/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 89f401da92f..eabb2969580 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Enter Username and Password", + "title": "Enter username and password", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" @@ -10,7 +10,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The vesync integration needs to re-authenticate your account", + "description": "The VeSync integration needs to re-authenticate your account", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" From f607b95c00b6ee8b0a9dfb7cd1a38251d5d83439 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 16:27:18 +0100 Subject: [PATCH 1238/1435] Add request made by `rest_command` to debug log (#139266) --- homeassistant/components/rest_command/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index f4c84bf72b5..c6a4206de4a 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -146,6 +146,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if content_type: headers[hdrs.CONTENT_TYPE] = content_type + _LOGGER.debug( + "Calling %s %s with headers: %s and payload: %s", + method, + request_url, + headers, + payload, + ) + try: async with getattr(websession, method)( request_url, From 27f7085b610936b7495844f70b7d268424111d5b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 25 Feb 2025 16:27:56 +0100 Subject: [PATCH 1239/1435] Create repair for configured unavailable backup agents (#137382) * Create repair for configured not loaded agents * Rework to repair issue * Extract logic to config function * Update test * Handle empty agend ids config update * Address review comment * Update tests * Address comment --- homeassistant/components/backup/config.py | 51 +++++- homeassistant/components/backup/manager.py | 9 + homeassistant/components/backup/strings.json | 4 + tests/components/backup/test_manager.py | 19 +- tests/components/backup/test_websocket.py | 182 +++++++++++++++++++ 5 files changed, 262 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 65f9f4789a6..f4fa2e8bac6 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -12,16 +12,19 @@ from typing import TYPE_CHECKING, Self, TypedDict from cronsim import CronSim from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.event import async_call_later, async_track_point_in_time from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util -from .const import LOGGER +from .const import DOMAIN, LOGGER from .models import BackupManagerError, Folder if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup +AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable" + CRON_PATTERN_DAILY = "{m} {h} * * *" CRON_PATTERN_WEEKLY = "{m} {h} * * {d}" @@ -151,6 +154,7 @@ class BackupConfig: retention=RetentionConfig(), schedule=BackupSchedule(), ) + self._hass = hass self._manager = manager def load(self, stored_config: StoredBackupConfig) -> None: @@ -182,6 +186,8 @@ class BackupConfig: self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) + if "agent_ids" in create_backup: + check_unavailable_agents(self._hass, self._manager) if retention is not UNDEFINED: new_retention = RetentionConfig(**retention) if new_retention != self.data.retention: @@ -562,3 +568,46 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter ) + + +@callback +def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None: + """Check for unavailable agents.""" + if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set( + manager.backup_agents + ): + LOGGER.debug( + "Agents %s are configured for automatic backup but are unavailable", + missing_agent_ids, + ) + + # Remove issues for unavailable agents that are not unavailable anymore. + issue_registry = ir.async_get(hass) + existing_missing_agent_issue_ids = { + issue_id + for domain, issue_id in issue_registry.issues + if domain == DOMAIN + and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID) + } + current_missing_agent_issue_ids = { + f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id + for agent_id in missing_agent_ids + } + for issue_id in existing_missing_agent_issue_ids - set( + current_missing_agent_issue_ids + ): + ir.async_delete_issue(hass, DOMAIN, issue_id) + for issue_id, agent_id in current_missing_agent_issue_ids.items(): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + learn_more_url="homeassistant://config/backup", + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_agents_unavailable", + translation_placeholders={ + "agent_id": agent_id, + "backup_settings": "/config/backup/settings", + }, + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 3bf31618b24..bd970d7708a 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -32,6 +32,7 @@ from homeassistant.helpers import ( instance_id, integration_platform, issue_registry as ir, + start, ) from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes @@ -47,6 +48,7 @@ from .agent import ( from .config import ( BackupConfig, CreateBackupParametersDict, + check_unavailable_agents, delete_backups_exceeding_configured_count, ) from .const import ( @@ -417,6 +419,13 @@ class BackupManager: } ) + @callback + def check_unavailable_agents_after_start(hass: HomeAssistant) -> None: + """Check unavailable agents after start.""" + check_unavailable_agents(hass, self) + + start.async_at_started(self.hass, check_unavailable_agents_after_start) + async def _add_platform( self, hass: HomeAssistant, diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 32d76ded049..c3047d3a4ac 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -1,5 +1,9 @@ { "issues": { + "automatic_backup_agents_unavailable": { + "title": "The backup location {agent_id} is unavailable", + "description": "The backup location `{agent_id}` is unavailable but is still configured for automatic backups.\n\nPlease visit the [automatic backup configuration page]({backup_settings}) to review and update your backup locations. Backups will not be uploaded to selected locations that are unavailable." + }, "automatic_backup_failed_create": { "title": "Automatic backup could not be created", "description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b2b7e083a51..3c72929cfe0 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -982,7 +982,15 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: None, None, True, - {}, + { + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, + }, ), ( ["test.remote", "test.unknown"], @@ -994,7 +1002,14 @@ 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.unknown"}, - } + }, + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, }, ), # Error raised in async_initiate_backup diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 9b2241882c4..404ba52de4b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -27,6 +27,7 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component @@ -34,7 +35,9 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + mock_backup_agent, setup_backup_integration, + setup_backup_platform, ) from tests.common import async_fire_time_changed, async_mock_service @@ -3244,6 +3247,185 @@ async def test_config_retention_days_logic( await hass.async_block_till_done() +async def test_configured_agents_unavailable_repair( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test creating and deleting repair issue for configured unavailable agents.""" + issue_id = "automatic_backup_agents_unavailable_test.agent" + ws_client = await hass_ws_client(hass) + hass_storage.update( + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test.agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + } + ) + + await setup_backup_integration(hass) + get_agents_mock = AsyncMock(return_value=[mock_backup_agent("agent")]) + register_listener_mock = Mock() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=get_agents_mock, + async_register_backup_agents_listener=register_listener_mock, + ), + ) + await hass.async_block_till_done() + + reload_backup_agents = register_listener_mock.call_args[1]["listener"] + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + # Reload the agents with no agents returned. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["test.agent"] + + # Update the automatic backup configuration removing the unavailable agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Reload the agents with one agent returned + # but not configured for automatic backups. + + get_agents_mock.return_value = [mock_backup_agent("agent")] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Update the automatic backup configuration and configure the test agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local", "test.agent"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Reload the agents with no agents returned again. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Update the automatic backup configuration removing all agents. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": []}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [] + + async def test_subscribe_event( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From ca1677cc461666a3ded07a63d091926dcb6e9ee0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 16:52:58 +0100 Subject: [PATCH 1240/1435] Improve description of `openweathermap.get_minute_forecast` action (#139267) --- homeassistant/components/openweathermap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 0692087bc23..1aa161c87dc 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -51,7 +51,7 @@ "services": { "get_minute_forecast": { "name": "Get minute forecast", - "description": "Get minute weather forecast." + "description": "Retrieves a minute-by-minute weather forecast for one hour." } }, "exceptions": { From fcffe5151ddc1ec86dafaca6e4348315b5b241ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 17:00:09 +0100 Subject: [PATCH 1241/1435] Use right import in ezviz (#139272) --- homeassistant/components/ezviz/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index ea032a8ec00..28ebc7279e6 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from propcache import cached_property +from propcache.api import cached_property from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image From 433c2cb43eba4ee8bc46706f49524557c46980c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Br=C3=B8ndum?= <34370407+brondum@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:00:35 +0100 Subject: [PATCH 1242/1435] Change touchline dependency to pytouchline_extended (#136362) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/touchline/climate.py | 15 ++++++++------- homeassistant/components/touchline/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index f7eec7c54f9..86526f4718b 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, NamedTuple -from pytouchline import PyTouchline +from pytouchline_extended import PyTouchline import voluptuous as vol from homeassistant.components.climate import ( @@ -53,12 +53,13 @@ def setup_platform( """Set up the Touchline devices.""" host = config[CONF_HOST] - py_touchline = PyTouchline() - number_of_devices = int(py_touchline.get_number_of_devices(host)) - add_entities( - (Touchline(PyTouchline(device_id)) for device_id in range(number_of_devices)), - True, - ) + py_touchline = PyTouchline(url=host) + number_of_devices = int(py_touchline.get_number_of_devices()) + devices = [ + Touchline(PyTouchline(id=device_id, url=host)) + for device_id in range(number_of_devices) + ] + add_entities(devices, True) class Touchline(ClimateEntity): diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json index c003cca97a4..6d25462408b 100644 --- a/homeassistant/components/touchline/manifest.json +++ b/homeassistant/components/touchline/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pytouchline"], "quality_scale": "legacy", - "requirements": ["pytouchline==0.7"] + "requirements": ["pytouchline_extended==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 55b4d140321..1b0af492388 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2497,7 +2497,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline -pytouchline==0.7 +pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl pytouchlinesl==0.3.0 From 9ec9110e1ee02e9d5cf45486388f59cceb02b0a2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:03:31 +0100 Subject: [PATCH 1243/1435] Rename description field to notes in Habitica action (#139271) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/services.py | 9 +++++---- homeassistant/components/habitica/services.yaml | 2 +- homeassistant/components/habitica/strings.json | 10 +++++----- tests/components/habitica/test_services.py | 7 ++++--- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 5e18477d142..353bcbbd39d 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -39,6 +39,7 @@ ATTR_REMOVE_TAG = "remove_tag" ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" +ATTR_NOTES = "notes" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 16bbeef9073..57005cf2b72 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -22,7 +22,7 @@ from habiticalib import ( ) import voluptuous as vol -from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME +from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( @@ -45,6 +45,7 @@ from .const import ( ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, + ATTR_NOTES, ATTR_PATH, ATTR_PRIORITY, ATTR_REMOVE_TAG, @@ -116,7 +117,7 @@ SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_RENAME): cv.string, - vol.Optional(ATTR_DESCRIPTION): cv.string, + vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_ALIAS): vol.All( @@ -566,8 +567,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if rename := call.data.get(ATTR_RENAME): data["text"] = rename - if (description := call.data.get(ATTR_DESCRIPTION)) is not None: - data["notes"] = description + if (notes := call.data.get(ATTR_NOTES)) is not None: + data["notes"] = notes tags = cast(list[str], call.data.get(ATTR_TAG)) remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b8479c1eeec..7b486690ef5 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -147,7 +147,7 @@ update_reward: rename: selector: text: - description: + notes: required: false selector: text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 75558cea078..1bb2fcbd9d7 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -12,8 +12,8 @@ "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", "rename_description": "The new title for the Habitica task.", - "description_name": "Update description", - "description_description": "The new description for the Habitica task.", + "notes_name": "Update notes", + "notes_description": "The new notes for the Habitica task.", "tag_name": "Add tags", "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", "remove_tag_name": "Remove tags", @@ -690,9 +690,9 @@ "name": "[%key:component::habitica::common::rename_name%]", "description": "[%key:component::habitica::common::rename_description%]" }, - "description": { - "name": "[%key:component::habitica::common::description_name%]", - "description": "[%key:component::habitica::common::description_description%]" + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { "name": "[%key:component::habitica::common::tag_name%]", diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 3f7ca14220b..a4442016784 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -17,6 +17,7 @@ from homeassistant.components.habitica.const import ( ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, + ATTR_NOTES, ATTR_PRIORITY, ATTR_REMOVE_TAG, ATTR_SKILL, @@ -38,7 +39,7 @@ from homeassistant.components.habitica.const import ( SERVICE_TRANSFORMATION, SERVICE_UPDATE_REWARD, ) -from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME +from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -984,9 +985,9 @@ async def test_task_not_found( ), ( { - ATTR_DESCRIPTION: "DESCRIPTION", + ATTR_NOTES: "NOTES", }, - Task(notes="DESCRIPTION"), + Task(notes="NOTES"), ), ( { From f3021b40abc5917740dc4950f7098b9daa58d041 Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:04:53 +0100 Subject: [PATCH 1244/1435] Add support for effects in Govee lights (#137846) --- .../govee_light_local/coordinator.py | 4 + .../components/govee_light_local/light.py | 62 ++ .../components/govee_light_local/strings.json | 24 + .../components/govee_light_local/conftest.py | 26 +- .../govee_light_local/test_config_flow.py | 60 +- .../govee_light_local/test_light.py | 624 ++++++++++++------ 6 files changed, 558 insertions(+), 242 deletions(-) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index ecbed0c4f65..530ade1f743 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -89,6 +89,10 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): """Set light color in kelvin.""" await device.set_temperature(temperature) + async def set_scene(self, device: GoveeController, scene: str) -> None: + """Set light scene.""" + await device.set_scene(scene) + @property def devices(self) -> list[GoveeDevice]: """Return a list of discovered Govee devices.""" diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 11ca53b53a1..984654477e9 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -10,9 +10,11 @@ from govee_local_api import GoveeDevice, GoveeLightFeatures from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityFeature, filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback @@ -25,6 +27,8 @@ from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry _LOGGER = logging.getLogger(__name__) +_NONE_SCENE = "none" + async def async_setup_entry( hass: HomeAssistant, @@ -50,10 +54,22 @@ async def async_setup_entry( class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): """Govee Light.""" + _attr_translation_key = "govee_light" _attr_has_entity_name = True _attr_name = None _attr_supported_color_modes: set[ColorMode] _fixed_color_mode: ColorMode | None = None + _attr_effect_list: list[str] | None = None + _attr_effect: str | None = None + _attr_supported_features: LightEntityFeature = LightEntityFeature(0) + _last_color_state: ( + tuple[ + ColorMode | str | None, + int | None, + tuple[int, int, int] | tuple[int | None] | None, + ] + | None + ) = None def __init__( self, @@ -80,6 +96,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): if GoveeLightFeatures.BRIGHTNESS & capabilities.features: color_modes.add(ColorMode.BRIGHTNESS) + if ( + GoveeLightFeatures.SCENES & capabilities.features + and capabilities.scenes + ): + self._attr_supported_features = LightEntityFeature.EFFECT + self._attr_effect_list = [_NONE_SCENE, *capabilities.scenes.keys()] + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) if len(self._attr_supported_color_modes) == 1: # If the light supports only a single color mode, set it now @@ -143,12 +166,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): if ATTR_RGB_COLOR in kwargs: self._attr_color_mode = ColorMode.RGB + self._attr_effect = None + self._last_color_state = None red, green, blue = kwargs[ATTR_RGB_COLOR] await self.coordinator.set_rgb_color(self._device, red, green, blue) elif ATTR_COLOR_TEMP_KELVIN in kwargs: self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_effect = None + self._last_color_state = None temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN] await self.coordinator.set_temperature(self._device, int(temperature)) + elif ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + if effect and self._attr_effect_list and effect in self._attr_effect_list: + if effect == _NONE_SCENE: + self._attr_effect = None + await self._restore_last_color_state() + else: + self._attr_effect = effect + self._save_last_color_state() + await self.coordinator.set_scene(self._device, effect) + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -159,3 +197,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): @callback def _update_callback(self, device: GoveeDevice) -> None: self.async_write_ha_state() + + def _save_last_color_state(self) -> None: + color_mode = self.color_mode + self._last_color_state = ( + color_mode, + self.brightness, + (self.color_temp_kelvin,) + if color_mode == ColorMode.COLOR_TEMP + else self.rgb_color, + ) + + async def _restore_last_color_state(self) -> None: + if self._last_color_state: + color_mode, brightness, color = self._last_color_state + if color: + if color_mode == ColorMode.RGB: + await self.coordinator.set_rgb_color(self._device, *color) + elif color_mode == ColorMode.COLOR_TEMP: + await self.coordinator.set_temperature(self._device, *color) + if brightness: + await self.coordinator.set_brightness( + self._device, int((float(brightness) / 255.0) * 100.0) + ) + self._last_color_state = None diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json index ad8f0f41ae7..49f3a2cbeb9 100644 --- a/homeassistant/components/govee_light_local/strings.json +++ b/homeassistant/components/govee_light_local/strings.json @@ -9,5 +9,29 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "light": { + "govee_light": { + "state_attributes": { + "effect": { + "state": { + "none": "None", + "sunrise": "Sunrise", + "sunset": "Sunset", + "movie": "Movie", + "dating": "Dating", + "romantic": "Romantic", + "twinkle": "Twinkle", + "candlelight": "Candlelight", + "snowflake": "Snowflake", + "energetic": "Energetic", + "breathe": "Breathe", + "crossing": "Crossing" + } + } + } + } + } } } diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 61a6394bd6a..a8b6955c384 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,15 +4,15 @@ from asyncio import Event from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapabilities -from govee_local_api.light_capabilities import COMMON_FEATURES +from govee_local_api import GoveeLightCapabilities, GoveeLightFeatures +from govee_local_api.light_capabilities import COMMON_FEATURES, SCENE_CODES import pytest from homeassistant.components.govee_light_local.coordinator import GoveeController @pytest.fixture(name="mock_govee_api") -def fixture_mock_govee_api(): +def fixture_mock_govee_api() -> Generator[AsyncMock]: """Set up Govee Local API fixture.""" mock_api = AsyncMock(spec=GoveeController) mock_api.start = AsyncMock() @@ -21,8 +21,20 @@ def fixture_mock_govee_api(): mock_api.turn_on_off = AsyncMock() mock_api.set_brightness = AsyncMock() mock_api.set_color = AsyncMock() + mock_api.set_scene = AsyncMock() mock_api._async_update_data = AsyncMock() - return mock_api + + with ( + patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_api, + ) as mock_controller, + patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_api, + ), + ): + yield mock_controller.return_value @pytest.fixture(name="mock_setup_entry") @@ -38,3 +50,9 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( features=COMMON_FEATURES, segments=[], scenes={} ) + +SCENE_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES | GoveeLightFeatures.SCENES, + segments=[], + scenes=SCENE_CODES, +) diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 103159f1a2b..e6e336a70f2 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -32,15 +32,9 @@ async def test_creating_entry_has_no_devices( mock_govee_api.devices = [] - with ( - patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ), - patch( - "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", - 0, - ), + with patch( + "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", + 0, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -67,24 +61,20 @@ async def test_creating_entry_has_with_devices( mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() - mock_govee_api.start.assert_awaited_once() - mock_setup_entry.assert_awaited_once() + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_awaited_once() async def test_creating_entry_errno( @@ -99,21 +89,17 @@ async def test_creating_entry_errno( mock_govee_api.start.side_effect = e mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT - await hass.async_block_till_done() + await hass.async_block_till_done() - assert mock_govee_api.start.call_count == 1 - mock_setup_entry.assert_not_awaited() + assert mock_govee_api.start.call_count == 1 + mock_setup_entry.assert_not_awaited() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 24bdbba9e11..c5dde6a9b9e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import DEFAULT_CAPABILITIES +from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES from tests.common import MockConfigEntry @@ -30,28 +30,24 @@ async def test_light_known_device( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None + light = hass.states.get("light.H615A") + assert light is not None - color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] - assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} + color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] + assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} - # Remove - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is None + # Remove + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is None async def test_light_unknown_device( @@ -69,26 +65,22 @@ async def test_light_unknown_device( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.XYZK") - assert light is not None + light = hass.states.get("light.XYZK") + assert light is not None - assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: - """Test adding a known device.""" + """Test remove device.""" mock_govee_api.devices = [ GoveeDevice( @@ -100,49 +92,41 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is not None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is not None - # Remove 1 - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 + # Remove 1 + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 async def test_light_setup_retry( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup retry.""" mock_govee_api.devices = [] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - with patch( - "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", - 0, - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + with patch( + "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", + 0, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_retry_eaddrinuse( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test retry on address already in use.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = EADDRINUSE @@ -156,21 +140,17 @@ async def test_light_setup_retry_eaddrinuse( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_error( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup error.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = ENETDOWN @@ -184,19 +164,15 @@ async def test_light_setup_error( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: - """Test adding a known device.""" + """Test light on and then off.""" mock_govee_api.devices = [ GoveeDevice( @@ -208,48 +184,44 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) - # Turn off - await hass.services.async_call( - "light", - "turn_off", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + # Turn off + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -264,67 +236,59 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness_pct": 50}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness_pct": 50}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + assert light.attributes["brightness"] == 127 - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -339,54 +303,312 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, - blocking=True, + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes["color_mode"] == ColorMode.RGB + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "kelvin": 4400}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == 4400 + assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=None, temperature=4400 + ) + + +async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turning on scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) - assert light.attributes["color_mode"] == ColorMode.RGB + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + +async def test_scene_restore_rgb( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore rgb color.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "kelvin": 4400}, - blocking=True, + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + + +async def test_scene_restore_temperature( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore color temperature.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["color_temp_kelvin"] == 4400 - assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=None, temperature=4400 + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = 3456 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == initial_color + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["color_temp_kelvin"] == initial_color + + +async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turn on 'none' scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + mock_govee_api.set_scene.assert_not_called() From 743cc428299135579dd87ffb2f7c2264c5ff0646 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 25 Feb 2025 08:08:32 -0800 Subject: [PATCH 1245/1435] Add Burbank Water and Power (BWP) virtual integration (#139027) --- .../components/burbank_water_and_power/__init__.py | 1 + .../components/burbank_water_and_power/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/burbank_water_and_power/__init__.py create mode 100644 homeassistant/components/burbank_water_and_power/manifest.json diff --git a/homeassistant/components/burbank_water_and_power/__init__.py b/homeassistant/components/burbank_water_and_power/__init__.py new file mode 100644 index 00000000000..2b82c8bd56b --- /dev/null +++ b/homeassistant/components/burbank_water_and_power/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Burbank Water and Power (BWP).""" diff --git a/homeassistant/components/burbank_water_and_power/manifest.json b/homeassistant/components/burbank_water_and_power/manifest.json new file mode 100644 index 00000000000..7b938d3b98b --- /dev/null +++ b/homeassistant/components/burbank_water_and_power/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "burbank_water_and_power", + "name": "Burbank Water and Power (BWP)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 01ff9d14d90..e3185251114 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -850,6 +850,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "burbank_water_and_power": { + "name": "Burbank Water and Power (BWP)", + "integration_type": "virtual", + "supported_by": "opower" + }, "caldav": { "name": "CalDAV", "integration_type": "hub", From 2bba185e4c32939ee4f45fa69f6f80c6b42348e5 Mon Sep 17 00:00:00 2001 From: Paul Traina Date: Tue, 25 Feb 2025 08:09:51 -0800 Subject: [PATCH 1246/1435] Update adext to 0.4.4 (#139151) --- homeassistant/components/alarmdecoder/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index ae1a2f4684d..c2c12792801 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["adext", "alarmdecoder"], - "requirements": ["adext==0.4.3"] + "requirements": ["adext==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b0af492388..00194d2f15b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -140,7 +140,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 072250cad20..180ed7d43e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 From 38cc26485a5ec055335b8dedfd2b601c87f6e285 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:21:05 +0100 Subject: [PATCH 1247/1435] Add sound mode support to Onkyo (#133531) --- homeassistant/components/onkyo/__init__.py | 17 ++- homeassistant/components/onkyo/config_flow.py | 139 +++++++++++++---- homeassistant/components/onkyo/const.py | 126 ++++++++++++++-- .../components/onkyo/media_player.py | 142 ++++++++++++++---- homeassistant/components/onkyo/strings.json | 23 ++- tests/components/onkyo/__init__.py | 6 +- tests/components/onkyo/test_config_flow.py | 128 ++++++++++------ 7 files changed, 447 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index fd5c0ba634a..2ebe86da561 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -1,6 +1,7 @@ """The onkyo component.""" from dataclasses import dataclass +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -9,10 +10,18 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, OPTION_INPUT_SOURCES, InputSource +from .const import ( + DOMAIN, + OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, + InputSource, + ListeningMode, +) from .receiver import Receiver, async_interview from .services import DATA_MP_ENTITIES, async_register_services +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -24,6 +33,7 @@ class OnkyoData: receiver: Receiver sources: dict[InputSource, str] + sound_modes: dict[ListeningMode, str] type OnkyoConfigEntry = ConfigEntry[OnkyoData] @@ -50,7 +60,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} - entry.runtime_data = OnkyoData(receiver, sources) + sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) + sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} + + entry.runtime_data = OnkyoData(receiver, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 228748d5257..5d941be959a 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Onkyo.""" +from collections.abc import Mapping import logging from typing import Any @@ -33,12 +34,14 @@ from .const import ( CONF_SOURCES, DOMAIN, OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, OPTION_VOLUME_RESOLUTION_DEFAULT, VOLUME_RESOLUTION_ALLOWED, InputSource, + ListeningMode, ) from .receiver import ReceiverInfo, async_discover, async_interview @@ -46,9 +49,14 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_ALL_MEANINGS = [ - input_source.value_meaning for input_source in InputSource -] +INPUT_SOURCES_DEFAULT: dict[str, str] = {} +LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_ALL_MEANINGS = { + input_source.value_meaning: input_source for input_source in InputSource +} +LISTENING_MODES_ALL_MEANINGS = { + listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode +} STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_RECONFIGURE_SCHEMA = vol.Schema( { @@ -59,7 +67,14 @@ STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend( { vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( - options=INPUT_SOURCES_ALL_MEANINGS, + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), multiple=True, mode=SelectSelectorMode.DROPDOWN, ) @@ -238,9 +253,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._receiver_info.host, }, options={ + **entry_options, OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES], }, ) @@ -250,12 +264,24 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - else: + + listening_modes: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_modes: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" + + if not errors: input_sources_store: dict[str, str] = {} for input_source_meaning in input_source_meanings: - input_source = InputSource.from_meaning(input_source_meaning) + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] input_sources_store[input_source.value] = input_source_meaning + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning in listening_modes: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_modes_store[listening_mode.value] = listening_mode_meaning + result = self.async_create_entry( title=self._receiver_info.model_name, data={ @@ -265,6 +291,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): OPTION_VOLUME_RESOLUTION: volume_resolution, OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, }, ) @@ -278,16 +305,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: [], + OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, } else: entry_options = reconfigure_entry.options suggested_values = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], - OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning - for input_source in entry_options[OPTION_INPUT_SOURCES] - ], } return self.async_show_form( @@ -356,6 +380,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): OPTION_VOLUME_RESOLUTION: volume_resolution, OPTION_MAX_VOLUME: max_volume, OPTION_INPUT_SOURCES: sources_store, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, }, ) @@ -373,7 +398,14 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema( ), vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( - options=INPUT_SOURCES_ALL_MEANINGS, + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), multiple=True, mode=SelectSelectorMode.DROPDOWN, ) @@ -387,6 +419,7 @@ class OnkyoOptionsFlowHandler(OptionsFlow): _data: dict[str, Any] _input_sources: dict[InputSource, str] + _listening_modes: dict[ListeningMode, str] async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -394,20 +427,40 @@ class OnkyoOptionsFlowHandler(OptionsFlow): """Manage the options.""" errors = {} - entry_options = self.config_entry.options + entry_options: Mapping[str, Any] = self.config_entry.options + entry_options = { + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + **entry_options, + } if user_input is not None: - self._input_sources = {} - for input_source_meaning in user_input[OPTION_INPUT_SOURCES]: - input_source = InputSource.from_meaning(input_source_meaning) - input_source_name = entry_options[OPTION_INPUT_SOURCES].get( - input_source.value, input_source_meaning - ) - self._input_sources[input_source] = input_source_name - - if not self._input_sources: + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - else: + + listening_mode_meanings: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_mode_meanings: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" + + if not errors: + self._input_sources = {} + for input_source_meaning in input_source_meanings: + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] + input_source_name = entry_options[OPTION_INPUT_SOURCES].get( + input_source.value, input_source_meaning + ) + self._input_sources[input_source] = input_source_name + + self._listening_modes = {} + for listening_mode_meaning in listening_mode_meanings: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_mode_name = entry_options[OPTION_LISTENING_MODES].get( + listening_mode.value, listening_mode_meaning + ) + self._listening_modes[listening_mode] = listening_mode_name + self._data = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], @@ -423,6 +476,10 @@ class OnkyoOptionsFlowHandler(OptionsFlow): InputSource(input_source).value_meaning for input_source in entry_options[OPTION_INPUT_SOURCES] ], + OPTION_LISTENING_MODES: [ + ListeningMode(listening_mode).value_meaning + for listening_mode in entry_options[OPTION_LISTENING_MODES] + ], } return self.async_show_form( @@ -440,28 +497,48 @@ class OnkyoOptionsFlowHandler(OptionsFlow): if user_input is not None: input_sources_store: dict[str, str] = {} for input_source_meaning, input_source_name in user_input[ - "input_sources" + OPTION_INPUT_SOURCES ].items(): - input_source = InputSource.from_meaning(input_source_meaning) + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] input_sources_store[input_source.value] = input_source_name + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning, listening_mode_name in user_input[ + OPTION_LISTENING_MODES + ].items(): + listening_mode = LISTENING_MODES_ALL_MEANINGS[listening_mode_meaning] + listening_modes_store[listening_mode.value] = listening_mode_name + return self.async_create_entry( data={ **self._data, OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, } ) - schema_dict: dict[Any, Selector] = {} - + input_sources_schema_dict: dict[Any, Selector] = {} for input_source, input_source_name in self._input_sources.items(): - schema_dict[ + input_sources_schema_dict[ vol.Required(input_source.value_meaning, default=input_source_name) ] = TextSelector() + listening_modes_schema_dict: dict[Any, Selector] = {} + for listening_mode, listening_mode_name in self._listening_modes.items(): + listening_modes_schema_dict[ + vol.Required(listening_mode.value_meaning, default=listening_mode_name) + ] = TextSelector() + return self.async_show_form( step_id="names", data_schema=vol.Schema( - {vol.Required("input_sources"): section(vol.Schema(schema_dict))} + { + vol.Required(OPTION_INPUT_SOURCES): section( + vol.Schema(input_sources_schema_dict) + ), + vol.Required(OPTION_LISTENING_MODES): section( + vol.Schema(listening_modes_schema_dict) + ), + } ), ) diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index bd4fe98ae7d..fcb1a8a0a9e 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -2,7 +2,7 @@ from enum import Enum import typing -from typing import ClassVar, Literal, Self +from typing import Literal, Self import pyeiscp @@ -24,7 +24,27 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args( OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 + +class EnumWithMeaning(Enum): + """Enum with meaning.""" + + value_meaning: str + + def __new__(cls, value: str) -> Self: + """Create enum.""" + obj = object.__new__(cls) + obj._value_ = value + obj.value_meaning = cls._get_meanings()[value] + + return obj + + @staticmethod + def _get_meanings() -> dict[str, str]: + raise NotImplementedError + + OPTION_INPUT_SOURCES = "input_sources" +OPTION_LISTENING_MODES = "listening_modes" _INPUT_SOURCE_MEANINGS = { "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", @@ -71,7 +91,7 @@ _INPUT_SOURCE_MEANINGS = { } -class InputSource(Enum): +class InputSource(EnumWithMeaning): """Receiver input source.""" DVR = "00" @@ -116,24 +136,100 @@ class InputSource(Enum): HDMI_7 = "57" MAIN_SOURCE = "80" - __meaning_mapping: ClassVar[dict[str, Self]] = {} # type: ignore[misc] + @staticmethod + def _get_meanings() -> dict[str, str]: + return _INPUT_SOURCE_MEANINGS - value_meaning: str - def __new__(cls, value: str) -> Self: - """Create InputSource enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = _INPUT_SOURCE_MEANINGS[value] +_LISTENING_MODE_MEANINGS = { + "00": "STEREO", + "01": "DIRECT", + "02": "SURROUND", + "03": "FILM ··· GAME RPG ··· ADVANCED GAME", + "04": "THX", + "05": "ACTION ··· GAME ACTION", + "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", + "07": "MONO MOVIE", + "08": "ORCHESTRA ··· CLASSICAL", + "09": "UNPLUGGED", + "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", + "0B": "TV LOGIC ··· DRAMA", + "0C": "ALL CH STEREO ··· EXTENDED STEREO", + "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", + "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", + "0F": "MONO", + "11": "PURE AUDIO ··· PURE DIRECT", + "12": "MULTIPLEX", + "13": "FULL MONO ··· MONO MUSIC", + "14": "DOLBY VIRTUAL/SURROUND ENHANCER", + "15": "DTS SURROUND SENSATION", + "16": "AUDYSSEY DSX", + "17": "DTS VIRTUAL:X", + "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", + "23": "STAGE (JAPAN GENRE CONTROL)", + "25": "ACTION (JAPAN GENRE CONTROL)", + "26": "MUSIC (JAPAN GENRE CONTROL)", + "2E": "SPORTS (JAPAN GENRE CONTROL)", + "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", + "41": "DOLBY EX/DTS ES", + "42": "THX CINEMA", + "43": "THX SURROUND EX", + "44": "THX MUSIC", + "45": "THX GAMES", + "50": "THX U(2)/S(2)/I/S CINEMA", + "51": "THX U(2)/S(2)/I/S MUSIC", + "52": "THX U(2)/S(2)/I/S GAMES", + "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", + "81": "PLII/PLIIx MUSIC", + "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", + "83": "NEO:6/NEO:X MUSIC", + "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", + "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", + "86": "PLII/PLIIx GAME", + "87": "NEURAL SURR", + "88": "NEURAL THX/NEURAL SURROUND", + "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", + "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", + "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", + "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", + "8D": "NEURAL THX CINEMA", + "8E": "NEURAL THX MUSIC", + "8F": "NEURAL THX GAMES", + "90": "PLIIz HEIGHT", + "91": "NEO:6 CINEMA DTS SURROUND SENSATION", + "92": "NEO:6 MUSIC DTS SURROUND SENSATION", + "93": "NEURAL DIGITAL MUSIC", + "94": "PLIIz HEIGHT + THX CINEMA", + "95": "PLIIz HEIGHT + THX MUSIC", + "96": "PLIIz HEIGHT + THX GAMES", + "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", + "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", + "99": "PLIIz HEIGHT + THX U2/S2 GAMES", + "9A": "NEO:X GAME", + "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", + "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", + "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", + "A3": "NEO:6 CINEMA + AUDYSSEY DSX", + "A4": "NEO:6 MUSIC + AUDYSSEY DSX", + "A5": "NEURAL SURROUND + AUDYSSEY DSX", + "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", + "A7": "DOLBY EX + AUDYSSEY DSX", + "FF": "AUTO SURROUND", +} - cls.__meaning_mapping[obj.value_meaning] = obj - return obj +class ListeningMode(EnumWithMeaning): + """Receiver listening mode.""" - @classmethod - def from_meaning(cls, meaning: str) -> Self: - """Get InputSource enum from its meaning.""" - return cls.__meaning_mapping[meaning] + _ignore_ = "ListeningMode _k _v _meaning" + + ListeningMode = vars() + for _k in _LISTENING_MODE_MEANINGS: + ListeningMode["I" + _k] = _k + + @staticmethod + def _get_meanings() -> dict[str, str]: + return _LISTENING_MODE_MEANINGS ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 711cede15bc..7c91fda5f78 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from enum import Enum from functools import cache import logging from typing import Any, Literal @@ -39,6 +40,7 @@ from .const import ( PYEISCP_COMMANDS, ZONES, InputSource, + ListeningMode, VolumeResolution, ) from .receiver import Receiver, async_discover @@ -63,6 +65,8 @@ CONF_SOURCES_DEFAULT = { "fm": "Radio", } +ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" + PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST): cv.string, @@ -79,23 +83,23 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( } ) -SUPPORT_ONKYO_WO_VOLUME = ( + +SUPPORTED_FEATURES_BASE = ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA ) -SUPPORT_ONKYO = ( - SUPPORT_ONKYO_WO_VOLUME - | MediaPlayerEntityFeature.VOLUME_SET +SUPPORTED_FEATURES_VOLUME = ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP ) -DEFAULT_PLAYABLE_SOURCES = ( - InputSource.from_meaning("FM"), - InputSource.from_meaning("AM"), - InputSource.from_meaning("DAB"), +PLAYABLE_SOURCES = ( + InputSource.FM, + InputSource.AM, + InputSource.DAB, ) ATTR_PRESET = "preset" @@ -118,7 +122,6 @@ AUDIO_INFORMATION_MAPPING = [ "auto_phase_control_phase", "upmix_mode", ] - VIDEO_INFORMATION_MAPPING = [ "video_input_port", "input_resolution", @@ -131,7 +134,6 @@ VIDEO_INFORMATION_MAPPING = [ "picture_mode", "input_hdr", ] -ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" type LibValue = str | tuple[str, ...] @@ -139,7 +141,19 @@ type LibValue = str | tuple[str, ...] def _get_single_lib_value(value: LibValue) -> str: if isinstance(value, str): return value - return value[0] + return value[-1] + + +def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: + result: dict[T, LibValue] = {} + for k, v in cmds["values"].items(): + try: + key = cls(k) + except ValueError: + continue + result[key] = v["name"] + + return result @cache @@ -154,15 +168,7 @@ def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: case "zone4": cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - result: dict[InputSource, LibValue] = {} - for k, v in cmds["values"].items(): - try: - source = InputSource(k) - except ValueError: - continue - result[source] = v["name"] - - return result + return _get_lib_mapping(cmds, InputSource) @cache @@ -170,6 +176,24 @@ def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: return {value: key for key, value in _input_source_lib_mappings(zone).items()} +@cache +def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: + match zone: + case "main": + cmds = PYEISCP_COMMANDS["main"]["LMD"] + case "zone2": + cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] + case _: + return {} + + return _get_lib_mapping(cmds, ListeningMode) + + +@cache +def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: + return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -303,6 +327,7 @@ async def async_setup_entry( volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] max_volume: float = entry.options[OPTION_MAX_VOLUME] sources = data.sources + sound_modes = data.sound_modes def connect_callback(receiver: Receiver) -> None: if not receiver.first_connect: @@ -331,6 +356,7 @@ async def async_setup_entry( volume_resolution=volume_resolution, max_volume=max_volume, sources=sources, + sound_modes=sound_modes, ) entities[zone] = zone_entity async_add_entities([zone_entity]) @@ -345,6 +371,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): _attr_should_poll = False _supports_volume: bool = False + _supports_sound_mode: bool = False _supports_audio_info: bool = False _supports_video_info: bool = False _query_timer: asyncio.TimerHandle | None = None @@ -357,6 +384,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): volume_resolution: VolumeResolution, max_volume: float, sources: dict[InputSource, str], + sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" self._receiver = receiver @@ -381,7 +409,27 @@ class OnkyoMediaPlayer(MediaPlayerEntity): value: key for key, value in self._source_mapping.items() } + self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) + self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + self._sound_mode_mapping = { + key: value + for key, value in sound_modes.items() + if key in self._sound_mode_lib_mapping + } + self._rev_sound_mode_mapping = { + value: key for key, value in self._sound_mode_mapping.items() + } + self._attr_source_list = list(self._rev_source_mapping) + self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) + + self._attr_supported_features = SUPPORTED_FEATURES_BASE + if zone == "main": + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE + self._supports_sound_mode = True + self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: @@ -394,13 +442,6 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._query_timer.cancel() self._query_timer = None - @property - def supported_features(self) -> MediaPlayerEntityFeature: - """Return media player features that are supported.""" - if self._supports_volume: - return SUPPORT_ONKYO - return SUPPORT_ONKYO_WO_VOLUME - @callback def _update_receiver(self, propname: str, value: Any) -> None: """Update a property in the receiver.""" @@ -466,6 +507,24 @@ class OnkyoMediaPlayer(MediaPlayerEntity): "input-selector" if self._zone == "main" else "selector", source_lib_single ) + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select listening sound mode.""" + if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_mode", + translation_placeholders={ + "invalid_sound_mode": sound_mode, + "entity_id": self.entity_id, + }, + ) + + sound_mode_lib = self._sound_mode_lib_mapping[ + self._rev_sound_mode_mapping[sound_mode] + ] + sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) + self._update_receiver("listening-mode", sound_mode_lib_single) + async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" self._update_receiver("hdmi-output-selector", hdmi_output) @@ -476,7 +535,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """Play radio station by preset number.""" if self.source is not None: source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: + if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @callback @@ -517,7 +576,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes.pop(ATTR_PRESET, None) self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) elif command in ["volume", "master-volume"] and value != "N/A": - self._supports_volume = True + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) volume_level: float = value / ( self._volume_resolution * self._max_volume / 100 @@ -535,6 +596,14 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes[ATTR_PRESET] = value elif ATTR_PRESET in self._attr_extra_state_attributes: del self._attr_extra_state_attributes[ATTR_PRESET] + elif command == "listening-mode" and value != "N/A": + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + self._parse_sound_mode(value) + self._query_av_info_delayed() elif command == "audio-information": self._supports_audio_info = True self._parse_audio_information(value) @@ -561,6 +630,21 @@ class OnkyoMediaPlayer(MediaPlayerEntity): ) self._attr_source = source_meaning + @callback + def _parse_sound_mode(self, mode_lib: LibValue) -> None: + sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + return + + sound_mode_meaning = sound_mode.value_meaning + _LOGGER.error( + 'Listening mode "%s" is invalid for entity: %s', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning + @callback def _parse_audio_information( self, audio_information: tuple[str] | Literal["N/A"] diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index b3b14efec44..d8131dd1149 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -27,17 +27,20 @@ "description": "Configure {name}", "data": { "volume_resolution": "Volume resolution", - "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]" + "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data::listening_modes%]" }, "data_description": { "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", - "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]" + "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data_description::listening_modes%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]", + "empty_listening_mode_list": "[%key:component::onkyo::options::error::empty_listening_mode_list%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -53,11 +56,13 @@ "init": { "data": { "max_volume": "Maximum volume limit (%)", - "input_sources": "Input sources" + "input_sources": "Input sources", + "listening_modes": "Listening modes" }, "data_description": { "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.", - "input_sources": "List of input sources supported by the receiver." + "input_sources": "List of input sources supported by the receiver.", + "listening_modes": "List of listening modes supported by the receiver." } }, "names": { @@ -65,12 +70,17 @@ "input_sources": { "name": "Input source names", "description": "Mappings of receiver's input sources to their names." + }, + "listening_modes": { + "name": "Listening mode names", + "description": "Mappings of receiver's listening modes to their names." } } } }, "error": { - "empty_input_source_list": "Input source list cannot be empty" + "empty_input_source_list": "Input source list cannot be empty", + "empty_listening_mode_list": "Listening mode list cannot be empty" } }, "issues": { @@ -84,6 +94,9 @@ } }, "exceptions": { + "invalid_sound_mode": { + "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." + }, "invalid_source": { "message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}." } diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 064075d109e..689711888d8 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -34,8 +34,9 @@ def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: data = {CONF_HOST: info.host} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( @@ -52,8 +53,9 @@ def create_empty_config_entry() -> MockConfigEntry: data = {CONF_HOST: ""} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 203cc22cf95..000e74d5308 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -11,7 +11,9 @@ from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, + OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, ) from homeassistant.config_entries import SOURCE_USER @@ -226,7 +228,11 @@ async def test_ssdp_discovery_success( select_result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + user_input={ + "volume_resolution": 200, + "input_sources": ["TV"], + "listening_modes": ["THX"], + }, ) assert select_result["type"] is FlowResultType.CREATE_ENTRY @@ -349,34 +355,6 @@ async def test_ssdp_discovery_no_host( assert result["reason"] == "unknown" -async def test_configure_empty_source_list( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configuration with no sources set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": []}, - ) - - assert configure_result["errors"] == {"input_sources": "empty_input_source_list"} - - async def test_configure_no_resolution( hass: HomeAssistant, default_mock_discovery ) -> None: @@ -404,33 +382,61 @@ async def test_configure_no_resolution( ) -async def test_configure_resolution_set( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configure with specified resolution.""" +async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: + """Test receiver configure.""" - init_result = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"}, ) - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "sample-host-name"}, ) - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["THX"], + }, ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} - assert configure_result["type"] is FlowResultType.CREATE_ENTRY - assert configure_result["options"]["volume_resolution"] == 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["options"] == { + OPTION_VOLUME_RESOLUTION: 200, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: {"12": "TV"}, + OPTION_LISTENING_MODES: {"04": "THX"}, + } async def test_configure_invalid_resolution_set( @@ -601,21 +607,26 @@ async def test_import_success( await hass.async_block_till_done() assert import_result["type"] is FlowResultType.CREATE_ENTRY - assert import_result["data"]["host"] == "host 1" - assert import_result["options"]["volume_resolution"] == 80 - assert import_result["options"]["max_volume"] == 100 - assert import_result["options"]["input_sources"] == { - "00": "Auxiliary", - "01": "Video", + assert import_result["data"] == {"host": "host 1"} + assert import_result["options"] == { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": { + "00": "Auxiliary", + "01": "Video", + }, + "listening_modes": {}, } @pytest.mark.parametrize( "ignore_translations", [ - [ # The schema is dynamically created from input sources + [ # The schema is dynamically created from input sources and listening modes "component.onkyo.options.step.names.sections.input_sources.data.TV", "component.onkyo.options.step.names.sections.input_sources.data_description.TV", + "component.onkyo.options.step.names.sections.listening_modes.data.STEREO", + "component.onkyo.options.step.names.sections.listening_modes.data_description.STEREO", ] ], ) @@ -635,6 +646,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) user_input={ OPTION_MAX_VOLUME: 42, OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["STEREO"], }, ) @@ -647,6 +659,20 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) user_input={ OPTION_MAX_VOLUME: 42, OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["STEREO"], }, ) @@ -657,6 +683,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) result["flow_id"], user_input={ OPTION_INPUT_SOURCES: {"TV": "television"}, + OPTION_LISTENING_MODES: {"STEREO": "Duophonia"}, }, ) @@ -665,4 +692,5 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) OPTION_VOLUME_RESOLUTION: old_volume_resolution, OPTION_MAX_VOLUME: 42.0, OPTION_INPUT_SOURCES: {"12": "television"}, + OPTION_LISTENING_MODES: {"00": "Duophonia"}, } From 4e904bf5a3f202da06f38a0c3d6843e6d0c1afa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Tue, 25 Feb 2025 17:21:31 +0100 Subject: [PATCH 1248/1435] Use new python library for picnic component (#139111) --- CODEOWNERS | 4 ++-- homeassistant/components/picnic/__init__.py | 2 +- homeassistant/components/picnic/config_flow.py | 4 ++-- homeassistant/components/picnic/coordinator.py | 4 ++-- homeassistant/components/picnic/manifest.json | 6 +++--- homeassistant/components/picnic/services.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/picnic/test_config_flow.py | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 87f170009f0..1052a58fe88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1146,8 +1146,8 @@ build.json @home-assistant/supervisor /tests/components/philips_js/ @elupus /homeassistant/components/pi_hole/ @shenxn /tests/components/pi_hole/ @shenxn -/homeassistant/components/picnic/ @corneyl -/tests/components/picnic/ @corneyl +/homeassistant/components/picnic/ @corneyl @codesalatdev +/tests/components/picnic/ @corneyl @codesalatdev /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index d2f023af79f..8de407133cd 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -1,6 +1,6 @@ """The Picnic integration.""" -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 4c8281f21de..a60086173a8 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -6,8 +6,8 @@ from collections.abc import Mapping import logging from typing import Any -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError import requests import voluptuous as vol diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index de686cad37d..9b23157dbf3 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -6,8 +6,8 @@ import copy from datetime import timedelta import logging -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 947dd0241d2..09f28da39a4 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -1,10 +1,10 @@ { "domain": "picnic", "name": "Picnic", - "codeowners": ["@corneyl"], + "codeowners": ["@corneyl", "@codesalatdev"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", - "loggers": ["python_picnic_api"], - "requirements": ["python-picnic-api==1.1.0"] + "loggers": ["python_picnic_api2"], + "requirements": ["python-picnic-api2==1.2.2"] } diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index bbc775891b7..76d7b8a6c44 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import cast -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall diff --git a/requirements_all.txt b/requirements_all.txt index 00194d2f15b..44cd0de4281 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2455,7 +2455,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.0 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 180ed7d43e4..b6c384e9944 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.0 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index 8d668b28c16..ba4c36682e1 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2.session import PicnicAuthError import requests from homeassistant import config_entries From a910fb879c9760da64f1db6d50787dbda03cab72 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 18:23:32 +0100 Subject: [PATCH 1249/1435] Bump securetar to 2025.2.1 (#139273) --- homeassistant/components/backup/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/backup/manifest.json b/homeassistant/components/backup/manifest.json index 6cbfb834c7f..db0719983b1 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.1.4"] + "requirements": ["cronsim==2.6", "securetar==2025.2.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e4f9466a10e..6a6c1dfc3ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.4 +securetar==2025.2.1 SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index 7a970b405a6..a7e3917eb90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2025.1.4", + "securetar==2025.2.1", "SQLAlchemy==2.0.38", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", diff --git a/requirements.txt b/requirements.txt index f002f0d6ecc..b378688106d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.4 +securetar==2025.2.1 SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 44cd0de4281..592add8e73e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2690,7 +2690,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2025.1.4 +securetar==2025.2.1 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6c384e9944..e9510d296fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2172,7 +2172,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2025.1.4 +securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense From a1d1f6ec97c68ecbb544cd40694d827ae429674a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 18:08:53 +0000 Subject: [PATCH 1250/1435] Fix race in async_get_integrations with multiple calls when an integration is not found (#139270) * Fix race in async_get_integrations with multiple calls when an integration is not found * Fix race in async_get_integrations with multiple calls when an integration is not found * Fix race in async_get_integrations with multiple calls when an integration is not found * tweaks * tweaks * tweaks * restore lost comment * tweak test * comment cache * improve test * improve comment --- homeassistant/loader.py | 68 ++++++++++++++++++++++------------------- tests/test_loader.py | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 92b588dbe15..008c2b057b2 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -40,7 +40,6 @@ from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .helpers.json import json_bytes, json_fragment -from .helpers.typing import UNDEFINED from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -125,9 +124,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( "components" ) -DATA_INTEGRATIONS: HassKey[dict[str, Integration | asyncio.Future[None]]] = HassKey( - "integrations" -) +DATA_INTEGRATIONS: HassKey[ + dict[str, Integration | asyncio.Future[Integration | IntegrationNotFound]] +] = HassKey("integrations") DATA_MISSING_PLATFORMS: HassKey[dict[str, bool]] = HassKey("missing_platforms") DATA_CUSTOM_COMPONENTS: HassKey[ dict[str, Integration] | asyncio.Future[dict[str, Integration]] @@ -1345,7 +1344,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio Raises IntegrationNotLoaded if the integration is not loaded. """ cache = hass.data[DATA_INTEGRATIONS] - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: return int_or_fut @@ -1355,7 +1354,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" cache = hass.data[DATA_INTEGRATIONS] - if type(int_or_fut := cache.get(domain, UNDEFINED)) is Integration: + if type(int_or_fut := cache.get(domain)) is Integration: return int_or_fut integrations_or_excs = await async_get_integrations(hass, [domain]) int_or_exc = integrations_or_excs[domain] @@ -1370,15 +1369,17 @@ async def async_get_integrations( """Get integrations.""" cache = hass.data[DATA_INTEGRATIONS] results: dict[str, Integration | Exception] = {} - needed: dict[str, asyncio.Future[None]] = {} - in_progress: dict[str, asyncio.Future[None]] = {} + needed: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} + in_progress: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} for domain in domains: - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: results[domain] = int_or_fut - elif int_or_fut is not UNDEFINED: - in_progress[domain] = cast(asyncio.Future[None], int_or_fut) + elif int_or_fut: + if TYPE_CHECKING: + assert isinstance(int_or_fut, asyncio.Future) + in_progress[domain] = int_or_fut elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") else: @@ -1386,14 +1387,13 @@ async def async_get_integrations( if in_progress: await asyncio.wait(in_progress.values()) - for domain in in_progress: - # When we have waited and it's UNDEFINED, it doesn't exist - # We don't cache that it doesn't exist, or else people can't fix it - # and then restart, because their config will never be valid. - if (int_or_fut := cache.get(domain, UNDEFINED)) is UNDEFINED: - results[domain] = IntegrationNotFound(domain) - else: - results[domain] = cast(Integration, int_or_fut) + # Here we retrieve the results we waited for + # instead of reading them from the cache since + # reading from the cache will have a race if + # the integration gets removed from the cache + # because it was not found. + for domain, future in in_progress.items(): + results[domain] = future.result() if not needed: return results @@ -1405,7 +1405,7 @@ async def async_get_integrations( for domain, future in needed.items(): if integration := custom.get(domain): results[domain] = cache[domain] = integration - future.set_result(None) + future.set_result(integration) for domain in results: if domain in needed: @@ -1419,18 +1419,24 @@ async def async_get_integrations( _resolve_integrations_from_root, hass, components, needed ) for domain, future in needed.items(): - int_or_exc = integrations.get(domain) - if not int_or_exc: - cache.pop(domain) - results[domain] = IntegrationNotFound(domain) - elif isinstance(int_or_exc, Exception): - cache.pop(domain) - exc = IntegrationNotFound(domain) - exc.__cause__ = int_or_exc - results[domain] = exc + if integration := integrations.get(domain): + results[domain] = cache[domain] = integration + future.set_result(integration) else: - results[domain] = cache[domain] = int_or_exc - future.set_result(None) + # We don't cache that it doesn't exist as configuration + # validation that relies on integrations being loaded + # would be unfixable. For example if a custom integration + # was temporarily removed. + # This allows restoring a missing integration to fix the + # validation error so the config validations checks do not + # block restarting. + del cache[domain] + exc = IntegrationNotFound(domain) + results[domain] = exc + # We don't use set_exception because + # we expect there will be cases where + # the a future exception is never retrieved + future.set_result(exc) return results diff --git a/tests/test_loader.py b/tests/test_loader.py index 4c3c4eb309f..8afe800144c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2039,3 +2039,59 @@ async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: json_loads(json_dumps(integration.manifest_json_fragment)) == integration.manifest ) + + +async def test_async_get_integrations_multiple_non_existent( + hass: HomeAssistant, +) -> None: + """Test async_get_integrations with multiple non-existent integrations.""" + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert isinstance(integrations["does_not_exist"], loader.IntegrationNotFound) + + async def slow_load_failure( + *args: Any, **kwargs: Any + ) -> dict[str, loader.Integration]: + await asyncio.sleep(0.1) + return {} + + with patch.object(hass, "async_add_executor_job", slow_load_failure): + task1 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist", "does_not_exist2"]) + ) + # Task one should now be waiting for executor job + task2 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist"]) + ) + # Task two should be waiting for the futures created in task one + task3 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist2", "does_not_exist"]) + ) + # Task three should be waiting for the futures created in task one + integrations_1 = await task1 + assert isinstance(integrations_1["does_not_exist"], loader.IntegrationNotFound) + assert isinstance(integrations_1["does_not_exist2"], loader.IntegrationNotFound) + integrations_2 = await task2 + assert isinstance(integrations_2["does_not_exist"], loader.IntegrationNotFound) + integrations_3 = await task3 + assert isinstance(integrations_3["does_not_exist2"], loader.IntegrationNotFound) + assert isinstance(integrations_3["does_not_exist"], loader.IntegrationNotFound) + + # Make sure IntegrationNotFound is not cached + # so configuration errors can be fixed as to + # not prevent Home Assistant from being restarted + integration = loader.Integration( + hass, + "custom_components.does_not_exist", + None, + { + "name": "Does not exist", + "domain": "does_not_exist", + }, + ) + with patch.object( + loader, + "_resolve_integrations_from_root", + return_value={"does_not_exist": integration}, + ): + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert integrations["does_not_exist"] is integration From cd4c79450b7a97c8994f16f8705290bba823e220 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 19:17:11 +0100 Subject: [PATCH 1251/1435] Bump python-overseerr to 0.7.1 (#139263) Co-authored-by: Shay Levy --- 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 6258481adcf..3c4321ebb37 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.7.0"] + "requirements": ["python-overseerr==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 592add8e73e..c318a069597 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.0 +python-overseerr==0.7.1 # homeassistant.components.picnic python-picnic-api2==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9510d296fe..d42434585d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1988,7 +1988,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.0 +python-overseerr==0.7.1 # homeassistant.components.picnic python-picnic-api2==1.2.2 From 2cd496fdafda5a63fb20464970779d16d677dffd Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Feb 2025 19:36:45 +0100 Subject: [PATCH 1252/1435] Add coordinator to SMHI (#139052) * Add coordinator to SMHI * Remove not needed logging * docstrings --- homeassistant/components/smhi/__init__.py | 13 ++- homeassistant/components/smhi/const.py | 7 ++ homeassistant/components/smhi/coordinator.py | 63 +++++++++++ homeassistant/components/smhi/entity.py | 17 +-- homeassistant/components/smhi/weather.py | 107 +++++++------------ tests/components/smhi/test_weather.py | 100 +++++++++-------- 6 files changed, 176 insertions(+), 131 deletions(-) create mode 100644 homeassistant/components/smhi/coordinator.py diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 59b32948879..1869b333071 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,6 +1,5 @@ """Support for the Swedish weather institute weather service.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -10,10 +9,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator + PLATFORMS = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Set up SMHI forecast as config entry.""" # Setting unique id where missing @@ -21,16 +22,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" hass.config_entries.async_update_entry(entry, unique_id=unique_id) + coordinator = SMHIDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + 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: SMHIConfigEntry) -> bool: """Unload a config entry.""" 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: SMHIConfigEntry) -> bool: """Migrate old entry.""" if entry.version > 3: diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index 11401119227..6cbf928d5e6 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -1,5 +1,7 @@ """Constants in smhi component.""" +from datetime import timedelta +import logging from typing import Final from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -12,3 +14,8 @@ HOME_LOCATION_NAME = "Home" DEFAULT_NAME = "Weather" ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" + +LOGGER = logging.getLogger(__package__) + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=31) +TIMEOUT = 10 diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py new file mode 100644 index 00000000000..511ba8b38d9 --- /dev/null +++ b/homeassistant/components/smhi/coordinator.py @@ -0,0 +1,63 @@ +"""DataUpdateCoordinator for the SMHI integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT + +type SMHIConfigEntry = ConfigEntry[SMHIDataUpdateCoordinator] + + +@dataclass +class SMHIForecastData: + """Dataclass for SMHI data.""" + + daily: list[SMHIForecast] + hourly: list[SMHIForecast] + + +class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): + """A SMHI Data Update Coordinator.""" + + config_entry: SMHIConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SMHIConfigEntry) -> None: + """Initialize the SMHI coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._smhi_api = SMHIPointForecast( + config_entry.data[CONF_LOCATION][CONF_LONGITUDE], + config_entry.data[CONF_LOCATION][CONF_LATITUDE], + session=aiohttp_client.async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> SMHIForecastData: + """Fetch data from SMHI.""" + try: + async with asyncio.timeout(TIMEOUT): + _forecast_daily = await self._smhi_api.async_get_daily_forecast() + _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + except SmhiForecastException as ex: + raise UpdateFailed( + "Failed to retrieve the forecast from the SMHI API" + ) from ex + + return SMHIForecastData( + daily=_forecast_daily, + hourly=_forecast_hourly, + ) diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 8d650d31945..89dca3360ca 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -2,16 +2,16 @@ from __future__ import annotations -import aiohttp -from pysmhi import SMHIPointForecast +from abc import abstractmethod from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import SMHIDataUpdateCoordinator -class SmhiWeatherBaseEntity(Entity): +class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): """Representation of a base weather entity.""" _attr_attribution = "Swedish weather institute (SMHI)" @@ -22,11 +22,11 @@ class SmhiWeatherBaseEntity(Entity): self, latitude: str, longitude: str, - session: aiohttp.ClientSession, + coordinator: SMHIDataUpdateCoordinator, ) -> None: """Initialize the SMHI base weather entity.""" + super().__init__(coordinator) self._attr_unique_id = f"{latitude}, {longitude}" - self._smhi_api = SMHIPointForecast(longitude, latitude, session=session) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{latitude}, {longitude}")}, @@ -34,3 +34,8 @@ class SmhiWeatherBaseEntity(Entity): model="v2", configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) + self.update_entity_data() + + @abstractmethod + def update_entity_data(self) -> None: + """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index b9cac9bdf2e..d2e31990012 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -2,14 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Mapping -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import Any, Final -import aiohttp -from pysmhi import SMHIForecast, SmhiForecastException +from pysmhi import SMHIForecast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -39,10 +36,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -53,17 +49,14 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, sun +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import sun from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT +from .coordinator import SMHIConfigEntry from .entity import SmhiWeatherBaseEntity -_LOGGER = logging.getLogger(__name__) - # Used to map condition from API results CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_CLOUDY: [5, 6], @@ -96,25 +89,25 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SMHIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from map location.""" location = config_entry.data - session = aiohttp_client.async_get_clientsession(hass) + coordinator = config_entry.runtime_data entity = SmhiWeather( location[CONF_LOCATION][CONF_LATITUDE], location[CONF_LOCATION][CONF_LONGITUDE], - session=session, + coordinator=coordinator, ) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title) - async_add_entities([entity], True) + async_add_entities([entity]) -class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): +class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): """Representation of a weather entity.""" _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -126,61 +119,37 @@ class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__( - self, - latitude: str, - longitude: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize the SMHI weather entity.""" - super().__init__(latitude, longitude, session) - self._forecast_daily: list[SMHIForecast] | None = None - self._forecast_hourly: list[SMHIForecast] | None = None - self._fail_count = 0 + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if daily_data := self.coordinator.data.daily: + self._attr_native_temperature = daily_data[0]["temperature"] + self._attr_humidity = daily_data[0]["humidity"] + self._attr_native_wind_speed = daily_data[0]["wind_speed"] + self._attr_wind_bearing = daily_data[0]["wind_direction"] + self._attr_native_visibility = daily_data[0]["visibility"] + self._attr_native_pressure = daily_data[0]["pressure"] + self._attr_native_wind_gust_speed = daily_data[0]["wind_gust"] + self._attr_cloud_coverage = daily_data[0]["total_cloud"] + self._attr_condition = CONDITION_MAP.get(daily_data[0]["symbol"]) + if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( + self.coordinator.hass + ): + self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional attributes.""" - if self._forecast_daily: + if daily_data := self.coordinator.data.daily: return { - ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0]["thunder"], + ATTR_SMHI_THUNDER_PROBABILITY: daily_data[0]["thunder"], } return None - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self) -> None: - """Refresh the forecast data from SMHI weather API.""" - try: - async with asyncio.timeout(TIMEOUT): - 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") - self._fail_count += 1 - if self._fail_count < 3: - async_call_later(self.hass, RETRY_TIMEOUT, self.retry_update) - 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]["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 - ): - self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT - await self.async_update_listeners(("daily", "hourly")) - - async def retry_update(self, _: datetime) -> None: - """Retry refresh weather forecast.""" - await self.async_update(no_throttle=True) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() def _get_forecast_data( self, forecast_data: list[SMHIForecast] | None @@ -219,10 +188,10 @@ class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): return data - async def async_forecast_daily(self) -> list[Forecast] | None: + def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self._forecast_daily) + return self._get_forecast_data(self.coordinator.data.daily) - async def async_forecast_hourly(self) -> list[Forecast] | None: + def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self._forecast_hourly) + return self._get_forecast_data(self.coordinator.data.hourly) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index f47566f2d5c..a09a9689d52 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -4,29 +4,27 @@ from datetime import datetime, timedelta from unittest.mock import patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from pysmhi import SMHIForecast, SmhiForecastException from pysmhi.const import API_POINT_FORECAST import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY -from homeassistant.components.smhi.weather import CONDITION_CLASSES, RETRY_TIMEOUT +from homeassistant.components.smhi.weather import CONDITION_CLASSES from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_FORECAST_CONDITION, - ATTR_WEATHER_CLOUD_COVERAGE, - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_VISIBILITY, - ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, - ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN, UnitOfSpeed +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfSpeed, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -104,33 +102,38 @@ async def test_clear_night( assert response == snapshot(name="clear-night_forecast") -async def test_properties_no_data(hass: HomeAssistant) -> None: +async def test_properties_no_data( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, +) -> None: """Test properties when no API data available.""" + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + with patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException("boom"), ): - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_ATTRIBUTION] == "Swedish weather institute (SMHI)" - assert ATTR_WEATHER_HUMIDITY not in state.attributes - assert ATTR_WEATHER_PRESSURE not in state.attributes - assert ATTR_WEATHER_TEMPERATURE not in state.attributes - assert ATTR_WEATHER_VISIBILITY not in state.attributes - assert ATTR_WEATHER_WIND_SPEED not in state.attributes - assert ATTR_WEATHER_WIND_BEARING not in state.attributes - assert ATTR_WEATHER_CLOUD_COVERAGE not in state.attributes - assert ATTR_SMHI_THUNDER_PROBABILITY not in state.attributes - assert ATTR_WEATHER_WIND_GUST_SPEED not in state.attributes async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @@ -215,11 +218,11 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", return_value=testdata, ), patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_hourly_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_hourly_forecast", return_value=None, ), ): @@ -246,55 +249,48 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @pytest.mark.parametrize("error", [SmhiForecastException(), TimeoutError()]) async def test_refresh_weather_forecast_retry( - hass: HomeAssistant, error: Exception + hass: HomeAssistant, + error: Exception, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, ) -> None: """Test the refresh weather forecast function.""" + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) - now = dt_util.utcnow() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() with patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=error, ) as mock_get_forecast: - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 1 - future = now + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 2 - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - assert mock_get_forecast.call_count == 3 - - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - # after three failed retries we stop retrying and go back to normal interval - assert mock_get_forecast.call_count == 3 - def test_condition_class() -> None: """Test condition class.""" From 75533463f794b935a28a4a08cb6d9b4dca798677 Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 18:41:47 +0000 Subject: [PATCH 1253/1435] Make Radarr unit translation lowercase (#139261) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/radarr/strings.json | 4 ++-- tests/components/radarr/test_sensor.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index cb624aff057..268d7955c1b 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -44,11 +44,11 @@ "sensor": { "movies": { "name": "Movies", - "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" + "unit_of_measurement": "movies" }, "queue": { "name": "Queue", - "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::unit_of_measurement%]" }, "start_time": { "name": "Start time" diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 9139e13a957..f6b14bffa80 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -68,13 +68,13 @@ async def test_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" state = hass.states.get("sensor.mock_title_movies") assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP state = hass.states.get("sensor.mock_title_queue") assert state.state == "2" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL From ef465521460f92286a725969796336f79673f6ac Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 25 Feb 2025 20:03:14 +0100 Subject: [PATCH 1254/1435] Add common state translation string for charging and discharging (#139074) add common state translation string for charging and discharging --- homeassistant/components/blue_current/strings.json | 2 +- homeassistant/components/bmw_connected_drive/strings.json | 2 +- homeassistant/components/enphase_envoy/strings.json | 4 ++-- homeassistant/components/lektrico/strings.json | 2 +- homeassistant/components/lg_thinq/strings.json | 4 ++-- homeassistant/components/matter/strings.json | 2 +- homeassistant/components/ohme/strings.json | 2 +- homeassistant/components/peblar/strings.json | 2 +- homeassistant/components/reolink/strings.json | 4 ++-- homeassistant/components/roborock/strings.json | 4 ++-- homeassistant/components/tesla_fleet/strings.json | 2 +- homeassistant/components/tesla_wall_connector/strings.json | 2 +- homeassistant/components/teslemetry/strings.json | 2 +- homeassistant/components/tessie/strings.json | 4 ++-- homeassistant/strings.json | 4 +++- 15 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 0154c794c33..2e48d768a74 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -28,7 +28,7 @@ "name": "Activity", "state": { "available": "Available", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "unavailable": "Unavailable", "error": "Error", "offline": "Offline" diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index edb0d5cfb12..4b16b719d8d 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -138,7 +138,7 @@ "name": "Charging status", "state": { "default": "Default", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "error": "Error", "complete": "Complete", "fully_charged": "Fully charged", diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 0c1facca1ea..b498c59e0d3 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -360,9 +360,9 @@ "acb_battery_state": { "name": "Battery state", "state": { - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "idle": "[%key:common::state::idle%]", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "full": "Full" } }, diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index e24700c9b09..3b4417c346a 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -86,7 +86,7 @@ "name": "State", "state": { "available": "Available", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "connected": "Connected", "error": "Error", "locked": "Locked", diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index a930860aa35..e1d3779f44b 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -411,7 +411,7 @@ "cancel": "Cancel", "carbonation": "Carbonation", "change_condition": "Settings Change", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_complete": "Charging completed", "checking_turbidity": "Detecting soil level", "cleaning": "Cleaning", @@ -498,7 +498,7 @@ "cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]", "carbonation": "[%key:component::lg_thinq::entity::sensor::current_state::state::carbonation%]", "change_condition": "[%key:component::lg_thinq::entity::sensor::current_state::state::change_condition%]", - "charging": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging%]", + "charging": "[%key:common::state::charging%]", "charging_complete": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging_complete%]", "checking_turbidity": "[%key:component::lg_thinq::entity::sensor::current_state::state::checking_turbidity%]", "cleaning": "[%key:component::lg_thinq::entity::sensor::current_state::state::cleaning%]", diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f299b5cb628..1404d0a9076 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -263,7 +263,7 @@ "paused": "[%key:common::state::paused%]", "error": "Error", "seeking_charger": "Seeking charger", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "docked": "Docked" } }, diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 387b28565b2..4c845daa8f0 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -74,7 +74,7 @@ "state": { "unplugged": "Unplugged", "plugged_in": "Plugged in", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", "pending_approval": "Pending approval", "finished": "Finished charging" diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 4a1500e54c5..416f1a2c062 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -107,7 +107,7 @@ "cp_state": { "name": "State", "state": { - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "error": "Error", "fault": "Fault", "invalid": "Invalid", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 3da463beddf..335ed92d32e 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -741,8 +741,8 @@ "battery_state": { "name": "Battery state", "state": { - "discharging": "Discharging", - "charging": "Charging", + "discharging": "[%key:common::state::discharging%]", + "charging": "[%key:common::state::charging%]", "chargecomplete": "Charge complete" } }, diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 8968ac020a2..eb058ea74e3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -128,7 +128,7 @@ "updating": "[%key:component::roborock::entity::sensor::status::state::updating%]", "washing": "Washing", "ready": "Ready", - "charging": "[%key:component::roborock::entity::sensor::status::state::charging%]", + "charging": "[%key:common::state::charging%]", "mop_washing": "Washing mop", "self_clean_cleaning": "Self clean cleaning", "self_clean_deep_cleaning": "Self clean deep cleaning", @@ -199,7 +199,7 @@ "cleaning": "Cleaning", "returning_home": "Returning home", "manual_mode": "Manual mode", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 540ea2b7135..331885893fe 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -329,7 +329,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 1a03207a012..b356a9f3ebc 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -42,7 +42,7 @@ "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", - "charging": "Charging" + "charging": "[%key:common::state::charging%]" } }, "status_code": { diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index b6b3d17e37c..9dc17fd2ef7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -415,7 +415,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index ccd17fbf6c8..4f0f5f67ebd 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -75,7 +75,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", @@ -212,7 +212,7 @@ "name": "State", "state": { "booting": "Booting", - "charging": "[%key:component::tessie::entity::sensor::charge_state_charging_state::state::charging%]", + "charging": "[%key:common::state::charging%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index fca55353aa0..f423c3bf59c 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -71,7 +71,9 @@ "standby": "Standby", "paused": "Paused", "home": "Home", - "not_home": "Away" + "not_home": "Away", + "charging": "Charging", + "discharging": "Discharging" }, "config_flow": { "title": { From 51c09c2aa4dae532b2f358cf238f6819f3947167 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 20:10:29 +0100 Subject: [PATCH 1255/1435] Add test fixture ignore_translations_for_mock_domains (#139235) * Add test fixture ignore_translations_for_mock_domains * Fix fixture * Avoid unnecessary attempt to get integration * Really fix fixture * Add forgotten parameter * Address review comment --- .../application_credentials/test_init.py | 25 +---- .../components/config/test_config_entries.py | 25 +---- tests/components/conftest.py | 93 ++++++++++++++++--- .../test_config_flow_failures.py | 68 +++++++------- .../test_silabs_multiprotocol_addon.py | 60 +++--------- tests/components/onkyo/test_config_flow.py | 2 +- tests/components/repairs/test_init.py | 30 +----- .../components/repairs/test_websocket_api.py | 68 +++----------- tests/components/sensor/test_recorder.py | 5 +- tests/components/synology_dsm/test_repairs.py | 2 +- .../components/websocket_api/test_commands.py | 9 +- tests/components/workday/test_repairs.py | 2 +- tests/components/zwave_js/test_repairs.py | 2 +- 13 files changed, 164 insertions(+), 227 deletions(-) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index b72d9653c2d..9896e4c9fc0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -423,10 +423,7 @@ async def test_import_named_credential( ] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( @@ -436,10 +433,7 @@ async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_other_domain( hass: HomeAssistant, ws_client: ClientFixture, @@ -567,10 +561,7 @@ async def test_config_flow_multiple_entries( ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_create_delete_credential( hass: HomeAssistant, ws_client: ClientFixture, @@ -616,10 +607,7 @@ async def test_config_flow_with_config_credential( assert result["data"].get("auth_implementation") == TEST_DOMAIN -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_import_without_setup(hass: HomeAssistant, config_credential) -> None: """Test import of credentials without setting up the integration.""" @@ -635,10 +623,7 @@ async def test_import_without_setup(hass: HomeAssistant, config_credential) -> N assert result.get("reason") == "missing_configuration" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_websocket_without_platform( hass: HomeAssistant, ws_client: ClientFixture diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index a31836b598c..739b79e22bd 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -400,10 +400,7 @@ async def test_available_flows( ############################ -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can initialize a flow.""" mock_platform(hass, "test.config_flow", None) @@ -513,10 +510,7 @@ async def test_initialize_flow_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.bla"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_abort(hass: HomeAssistant, client: TestClient) -> None: """Test a flow that aborts.""" mock_platform(hass, "test.config_flow", None) @@ -826,10 +820,7 @@ async def test_get_progress_index_unauth( assert response["error"]["code"] == "unauthorized" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can query the API for same result as we get from init a flow.""" mock_platform(hass, "test.config_flow", None) @@ -863,10 +854,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non assert data == data2 -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow_unauth( hass: HomeAssistant, client: TestClient, hass_admin_user: MockUser ) -> None: @@ -2870,10 +2858,7 @@ async def test_flow_with_multiple_schema_errors_base( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.reconfigure_successful"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.usefixtures("freezer") async def test_supports_reconfigure( hass: HomeAssistant, diff --git a/tests/components/conftest.py b/tests/components/conftest.py index cf10e2b8dfd..6d6d0d4641f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -22,6 +22,7 @@ from aiohasupervisor.models import ( import pytest import voluptuous as vol +from homeassistant import components, loader from homeassistant.components import repairs from homeassistant.config_entries import ( DISCOVERY_SOURCES, @@ -605,6 +606,7 @@ def _validate_translation_placeholders( async def _validate_translation( hass: HomeAssistant, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], category: str, component: str, key: str, @@ -614,7 +616,25 @@ async def _validate_translation( ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" + if component in ignore_translations_for_mock_domains: + try: + integration = await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + return + component_paths = components.__path__ + if not any( + Path(f"{component_path}/{component}") == integration.file_path + for component_path in component_paths + ): + return + # If the integration exists, translation errors should be ignored via the + # ignore_missing_translations fixture instead of the + # ignore_translations_for_mock_domains fixture. + translation_errors[full_key] = f"The integration '{component}' exists" + return + translations = await async_get_translations(hass, "en", category, [component]) + if (translation := translations.get(full_key)) is not None: _validate_translation_placeholders( full_key, translation, description_placeholders, translation_errors @@ -625,6 +645,18 @@ async def _validate_translation( return if translation_errors.get(full_key) in {"used", "unused"}: + # If the does not integration exist, translation errors should be ignored + # via the ignore_translations_for_mock_domains fixture instead of the + # ignore_missing_translations fixture. + try: + await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + translation_errors[full_key] = ( + f"Translation not found for {component}: `{category}.{key}`. " + f"The integration '{component}' does not exist." + ) + return + # This translation key is in the ignore list, mark it as used translation_errors[full_key] = "used" return @@ -636,11 +668,22 @@ async def _validate_translation( @pytest.fixture -def ignore_translations() -> str | list[str]: - """Ignore specific translations. +def ignore_missing_translations() -> str | list[str]: + """Ignore specific missing translations. - Override or parametrize this fixture with a fixture that returns, - a list of translation that should be ignored. + Override or parametrize this fixture with a fixture that returns + a list of missing translation that should be ignored. + """ + return [] + + +@pytest.fixture +def ignore_translations_for_mock_domains() -> str | list[str]: + """Don't validate translations for specific domains. + + Override or parametrize this fixture with a fixture that returns + a list of domains for which translations should not be validated. + This should only be used when testing mocked integrations. """ return [] @@ -673,6 +716,7 @@ async def _check_step_or_section_translations( translation_prefix: str, description_placeholders: dict[str, str], data_schema: vol.Schema | None, + ignore_translations_for_mock_domains: set[str], ) -> None: # neither title nor description are required # - title defaults to integration name @@ -681,6 +725,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}", @@ -702,6 +747,7 @@ async def _check_step_or_section_translations( f"{translation_prefix}.sections.{data_key}", description_placeholders, data_value.schema, + ignore_translations_for_mock_domains, ) continue iqs_config_flow = _get_integration_quality_scale_rule( @@ -712,6 +758,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}.{data_key}", @@ -725,6 +772,7 @@ async def _check_config_flow_result_translations( flow: FlowHandler, result: FlowResult[FlowContext, str], translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if result["type"] is FlowResultType.CREATE_ENTRY: # No need to check translations for a completed flow @@ -760,6 +808,7 @@ async def _check_config_flow_result_translations( f"{key_prefix}step.{step_id}", result["description_placeholders"], result["data_schema"], + ignore_translations_for_mock_domains, ) if errors := result.get("errors"): @@ -767,6 +816,7 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{key_prefix}error.{error}", @@ -782,6 +832,7 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{key_prefix}abort.{result['reason']}", @@ -793,6 +844,7 @@ async def _check_create_issue_translations( issue_registry: ir.IssueRegistry, issue: ir.IssueEntry, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if issue.translation_key is None: # `translation_key` is only None on dismissed issues @@ -800,6 +852,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.title", @@ -810,6 +863,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.description", @@ -831,6 +885,7 @@ async def _check_exception_translation( exception: HomeAssistantError, translation_errors: dict[str, str], request: pytest.FixtureRequest, + ignore_translations_for_mock_domains: set[str], ) -> None: if exception.translation_key is None: if ( @@ -844,6 +899,7 @@ async def _check_exception_translation( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, "exceptions", exception.translation_domain, f"{exception.translation_key}.message", @@ -853,7 +909,9 @@ async def _check_exception_translation( @pytest.fixture(autouse=True) async def check_translations( - ignore_translations: str | list[str], request: pytest.FixtureRequest + ignore_missing_translations: str | list[str], + ignore_translations_for_mock_domains: str | list[str], + request: pytest.FixtureRequest, ) -> AsyncGenerator[None]: """Check that translation requirements are met. @@ -862,11 +920,16 @@ async def check_translations( - issue registry entries - action (service) exceptions """ - if not isinstance(ignore_translations, list): - ignore_translations = [ignore_translations] + if not isinstance(ignore_missing_translations, list): + ignore_missing_translations = [ignore_missing_translations] + + if not isinstance(ignore_translations_for_mock_domains, list): + ignored_domains = {ignore_translations_for_mock_domains} + else: + ignored_domains = set(ignore_translations_for_mock_domains) # Set all ignored translation keys to "unused" - translation_errors = {k: "unused" for k in ignore_translations} + translation_errors = {k: "unused" for k in ignore_missing_translations} translation_coros = set() @@ -881,7 +944,7 @@ async def check_translations( ) -> FlowResult: result = await _original_flow_manager_async_handle_step(self, flow, *args) await _check_config_flow_result_translations( - self, flow, result, translation_errors + self, flow, result, translation_errors, ignored_domains ) return result @@ -892,7 +955,9 @@ async def check_translations( self, domain, issue_id, *args, **kwargs ) translation_coros.add( - _check_create_issue_translations(self, result, translation_errors) + _check_create_issue_translations( + self, result, translation_errors, ignored_domains + ) ) return result @@ -920,7 +985,11 @@ async def check_translations( except HomeAssistantError as err: translation_coros.add( _check_exception_translation( - self._hass, err, translation_errors, request + self._hass, + err, + translation_errors, + request, + ignored_domains, ) ) raise @@ -950,7 +1019,7 @@ async def check_translations( # Some ignored translations were not used pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " - "Please remove them from the ignore_translations fixture." + "Please remove them from the ignore_missing_translations fixture." ) for description in translation_errors.values(): if description != "used": diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 8c2ee4b90ba..fb38704ae61 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -35,8 +35,8 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) @pytest.mark.parametrize( "next_step", @@ -69,8 +69,8 @@ async def test_config_flow_cannot_probe_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_not_hassio_wrong_firmware( hass: HomeAssistant, @@ -98,8 +98,8 @@ async def test_config_flow_zigbee_not_hassio_wrong_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_already_running( hass: HomeAssistant, @@ -136,8 +136,8 @@ async def test_config_flow_zigbee_flasher_addon_already_running( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -173,8 +173,8 @@ async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_install_fails( hass: HomeAssistant, @@ -207,8 +207,8 @@ async def test_config_flow_zigbee_flasher_addon_install_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_set_config_fails( hass: HomeAssistant, @@ -245,8 +245,8 @@ async def test_config_flow_zigbee_flasher_addon_set_config_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -310,8 +310,8 @@ async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to Zigbee firmware not being detected.""" @@ -346,8 +346,8 @@ async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> Non @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio_thread"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: """Test when the stick is used with a non-hassio setup and Thread is selected.""" @@ -373,8 +373,8 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -401,8 +401,8 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.otbr_addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> None: """Test failure case when the Thread addon is already running.""" @@ -440,8 +440,8 @@ async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -471,8 +471,8 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be configured.""" @@ -502,8 +502,8 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -567,8 +567,8 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to OpenThread firmware not being detected.""" @@ -609,8 +609,8 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.zha_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_zigbee_to_thread_zha_configured( hass: HomeAssistant, @@ -657,8 +657,8 @@ async def test_options_flow_zigbee_to_thread_zha_configured( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.otbr_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 22e3e338986..fbba3d42bbe 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -450,10 +450,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.not_hassio"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_non_hassio( hass: HomeAssistant, ) -> None: @@ -766,10 +763,7 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_already_running"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_already_running_failure( hass: HomeAssistant, addon_info, @@ -881,10 +875,7 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_install_failure( hass: HomeAssistant, addon_info, @@ -951,10 +942,7 @@ async def test_option_flow_flasher_install_failure( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_addon_flash_failure( hass: HomeAssistant, addon_info, @@ -1017,10 +1005,7 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["description_placeholders"]["addon_name"] == "Silicon Labs Flasher" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1082,10 +1067,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( mock_initiate_migration.assert_called_once() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), @@ -1187,10 +1169,7 @@ async def test_option_flow_do_not_install_multi_pan_addon( assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_install_fails( hass: HomeAssistant, addon_store_info, @@ -1234,10 +1213,7 @@ async def test_option_flow_install_multi_pan_addon_install_fails( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_start_fails( hass: HomeAssistant, addon_store_info, @@ -1299,10 +1275,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( assert result["reason"] == "addon_start_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_set_config_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_set_options_fails( hass: HomeAssistant, addon_store_info, @@ -1346,10 +1319,7 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( assert result["reason"] == "addon_set_config_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_info_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_addon_info_fails( hass: HomeAssistant, addon_store_info, @@ -1373,10 +1343,7 @@ async def test_option_flow_addon_info_fails( assert result["reason"] == "addon_info_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1432,10 +1399,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( set_addon_options.assert_not_called() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 000e74d5308..28186503ead 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -620,7 +620,7 @@ async def test_import_success( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ [ # The schema is dynamically created from input sources and listening modes "component.onkyo.options.step.names.sections.input_sources.data.TV", diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index e78563503f1..9c4a0dfbd2a 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -21,16 +21,7 @@ from tests.common import mock_platform from tests.typing import WebSocketGenerator -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -170,14 +161,7 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -347,10 +331,7 @@ async def test_ignore_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, @@ -505,10 +486,7 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 399292fb83f..bbaf70e0a9b 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -151,10 +151,7 @@ async def mock_repairs_integration(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_dismiss_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -238,10 +235,7 @@ async def test_dismiss_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_non_existing_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -289,19 +283,19 @@ async def test_fix_non_existing_issue( @pytest.mark.parametrize( - ("domain", "step", "description_placeholders", "ignore_translations"), + ( + "domain", + "step", + "description_placeholders", + "ignore_translations_for_mock_domains", + ), [ - ( - "fake_integration", - "custom_step", - None, - ["component.fake_integration.issues.abc_123.title"], - ), + ("fake_integration", "custom_step", None, ["fake_integration"]), ( "fake_integration_default_handler", "confirm", {"abc": "123"}, - ["component.fake_integration_default_handler.issues.abc_123.title"], + ["fake_integration_default_handler"], ), ], ) @@ -398,10 +392,7 @@ async def test_fix_issue_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_get_progress_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -433,10 +424,7 @@ async def test_get_progress_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_step_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -468,16 +456,7 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, @@ -569,15 +548,7 @@ async def test_list_issues( } -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.fake_integration.issues.abc_123.title", - "component.fake_integration.issues.abc_123.fix_flow.abort.not_given", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_issue_aborted( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -639,16 +610,7 @@ async def test_fix_issue_aborted( assert msg["result"]["issues"][0] == first_issue -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_get_issue_data( hass: HomeAssistant, hass_ws_client: WebSocketGenerator diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 615960defbb..a5b6a07dde5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -5449,12 +5449,11 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: assert ATTR_FRIENDLY_NAME in states[0].attributes +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ [ - "component.test.issues..title", - "component.test.issues..description", "component.sensor.issues..title", "component.sensor.issues..description", ] diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py index 0dea980b553..a094928b837 100644 --- a/tests/components/synology_dsm/test_repairs.py +++ b/tests/components/synology_dsm/test_repairs.py @@ -256,7 +256,7 @@ async def test_missing_backup_no_shares( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.synology_dsm.issues.other_issue.title"], ) async def test_other_fixable_issues( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index baa939c411b..c0114cde42b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -540,10 +540,7 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.exceptions.custom_error.message"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_call_service_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: @@ -2394,9 +2391,7 @@ async def test_execute_script( ), ], ) -@pytest.mark.parametrize( - "ignore_translations", ["component.test.exceptions.test_error.message"] -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_execute_script_err_localization( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index adbae5676e6..09b0149a424 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -430,7 +430,7 @@ async def test_bad_date_holiday( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.workday.issues.issue_1.title"], ) async def test_other_fixable_issues( diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index a46320168eb..1d0f74c7269 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -180,7 +180,7 @@ async def test_device_config_file_changed_ignore_step( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.zwave_js.issues.invalid_issue.title"], ) async def test_invalid_issue( From 19704cff0418a970be9b8c70319e20183f305d58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 19:10:54 +0000 Subject: [PATCH 1256/1435] Fix grammar in loader comments (#139276) https://github.com/home-assistant/core/pull/139270#discussion_r1970315129 --- homeassistant/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 008c2b057b2..3bc33f8374c 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1435,7 +1435,7 @@ async def async_get_integrations( results[domain] = exc # We don't use set_exception because # we expect there will be cases where - # the a future exception is never retrieved + # the future exception is never retrieved future.set_result(exc) return results From 570e11ba5b5bb6a5a37603e5acfa0e019a224e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 20:22:30 +0100 Subject: [PATCH 1257/1435] Bump aiohomeconnect to 0.15.0 (#139277) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 06325afaed8..28714b31679 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +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.15.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c318a069597..c8265568525 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.12.3 +aiohomeconnect==0.15.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d42434585d1..bc065805b2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.12.3 +aiohomeconnect==0.15.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 From b8a0cdea124c87c0a9e663f451f2841ea8491026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 20:50:42 +0100 Subject: [PATCH 1258/1435] Add current cavity temperature sensor to Home Connect (#139282) --- homeassistant/components/home_connect/sensor.py | 6 ++++++ homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index be0b621b508..3f85bc3404c 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -179,6 +179,12 @@ SENSORS = ( ], translation_key="last_selected_map", ), + HomeConnectSensorEntityDescription( + key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_cavity_temperature", + ), ) EVENT_SENSORS = ( diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 672ad364365..4fabd1e1c50 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1529,6 +1529,9 @@ "map3": "Map 3" } }, + "current_cavity_temperature": { + "name": "Current cavity temperature" + }, "freezer_door_alarm": { "name": "Freezer door alarm", "state": { From df6a5d7459cfe6348c5628857c19ecc99956cced Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 25 Feb 2025 23:24:38 +0300 Subject: [PATCH 1259/1435] Bump anthropic to 0.47.2 (#139283) --- homeassistant/components/anthropic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index b5cbb36c034..797a7299d16 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.44.0"] + "requirements": ["anthropic==0.47.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c8265568525..79015872b6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -476,7 +476,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.44.0 +anthropic==0.47.2 # homeassistant.components.mcp_server anyio==4.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc065805b2e..479557ba478 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.44.0 +anthropic==0.47.2 # homeassistant.components.mcp_server anyio==4.8.0 From fd47d6578e866de8a8bdb0fc64d652960c8fc3f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 21:31:24 +0100 Subject: [PATCH 1260/1435] Adjust recorder validate_statistics handler (#139229) --- homeassistant/components/recorder/websocket_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 03d9e725170..d23ecab3dac 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -297,13 +297,13 @@ async def ws_list_statistic_ids( async def ws_validate_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Fetch a list of available statistic_id.""" + """Validate statistics and return issues found.""" instance = get_instance(hass) - statistic_ids = await instance.async_add_executor_job( + validation_issues = await instance.async_add_executor_job( validate_statistics, hass, ) - connection.send_result(msg["id"], statistic_ids) + connection.send_result(msg["id"], validation_issues) @websocket_api.websocket_command( From 03f6508bd89f41eee089634739b0581be5849669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 25 Feb 2025 21:37:01 +0100 Subject: [PATCH 1261/1435] Fix re-connect logic in Apple TV integration (#139289) --- homeassistant/components/apple_tv/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index f4417134b37..b911b3cec99 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -233,7 +233,6 @@ class AppleTVManager(DeviceListener): pass except Exception: _LOGGER.exception("Failed to connect") - await self.disconnect() async def _connect_loop(self) -> None: """Connect loop background task function.""" From fe348e17a3b709660fb5d24a193461fb19519892 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 20:43:06 +0000 Subject: [PATCH 1262/1435] Revert "Bump stookwijzer==1.5.8" (#139287) --- homeassistant/components/stookwijzer/__init__.py | 2 ++ homeassistant/components/stookwijzer/config_flow.py | 2 ++ homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index cb198749c52..d8b9561bde9 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,6 +9,7 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,6 +44,7 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 124b0f8bfbb..32b4836763f 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,6 +27,7 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 86fccf64db1..8243b903e8d 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.8"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 79015872b6d..7caab6809ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.8 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 479557ba478..3ca116b3c24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.8 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 81db3dea4183918a85d4d264f253aa27f26c9293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 20:56:39 +0000 Subject: [PATCH 1263/1435] Add option to ESPHome to subscribe to logs (#139073) --- .../components/esphome/config_flow.py | 5 ++ homeassistant/components/esphome/const.py | 1 + homeassistant/components/esphome/manager.py | 39 +++++++++++ homeassistant/components/esphome/strings.json | 3 +- tests/components/esphome/conftest.py | 26 +++++++- tests/components/esphome/test_config_flow.py | 66 ++++++++++++++++--- tests/components/esphome/test_manager.py | 62 ++++++++++++++++- 7 files changed, 189 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 695131b19f7..955a93cd2b7 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -41,6 +41,7 @@ from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, CONF_NOISE_PSK, + CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, @@ -508,6 +509,10 @@ class OptionsFlowHandler(OptionsFlow): CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS ), ): bool, + vol.Required( + CONF_SUBSCRIBE_LOGS, + default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 143aaa6342a..aabebad01b6 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -5,6 +5,7 @@ from awesomeversion import AwesomeVersion DOMAIN = "esphome" CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" +CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 5f5ee1241f7..c73268de747 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from functools import partial import logging +import re from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( @@ -16,6 +17,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, + LogLevel, ReconnectLogic, RequiresEncryptionAPIError, UserService, @@ -61,6 +63,7 @@ from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, + CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_URL, DOMAIN, @@ -74,8 +77,30 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] + SubscribeLogsResponse, + ) + + _LOGGER = logging.getLogger(__name__) +LOG_LEVEL_TO_LOGGER = { + LogLevel.LOG_LEVEL_NONE: logging.DEBUG, + LogLevel.LOG_LEVEL_ERROR: logging.ERROR, + LogLevel.LOG_LEVEL_WARN: logging.WARNING, + LogLevel.LOG_LEVEL_INFO: logging.INFO, + LogLevel.LOG_LEVEL_CONFIG: logging.INFO, + LogLevel.LOG_LEVEL_DEBUG: logging.DEBUG, + LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG, + LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG, +} +# 7-bit and 8-bit C1 ANSI sequences +# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python +ANSI_ESCAPE_78BIT = re.compile( + rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])" +) + @callback def _async_check_firmware_version( @@ -341,6 +366,18 @@ class ESPHomeManager: # Re-connection logic will trigger after this await self.cli.disconnect() + def _async_on_log(self, msg: SubscribeLogsResponse) -> None: + """Handle a log message from the API.""" + logger_level = LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG) + if _LOGGER.isEnabledFor(logger_level): + log: bytes = msg.message + _LOGGER.log( + logger_level, + "%s: %s", + self.entry.title, + ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), + ) + async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry @@ -352,6 +389,8 @@ class ESPHomeManager: cli = self.cli stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id + if entry.options.get(CONF_SUBSCRIBE_LOGS): + cli.subscribe_logs(self._async_on_log, LogLevel.LOG_LEVEL_VERY_VERBOSE) results = await asyncio.gather( create_eager_task(cli.device_info()), create_eager_task(cli.list_entities_services()), diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 81b58de8df2..1534a49e365 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -54,7 +54,8 @@ "step": { "init": { "data": { - "allow_service_calls": "Allow the device to perform Home Assistant actions." + "allow_service_calls": "Allow the device to perform Home Assistant actions.", + "subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } } diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 2b7c127efd3..07f6c6ea697 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -6,7 +6,7 @@ import asyncio from asyncio import Event from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( @@ -17,6 +17,7 @@ from aioesphomeapi import ( EntityInfo, EntityState, HomeassistantServiceCall, + LogLevel, ReconnectLogic, UserService, VoiceAssistantAnnounceFinished, @@ -42,6 +43,10 @@ from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG from tests.common import MockConfigEntry +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import SubscribeLogsResponse + + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -154,6 +159,7 @@ def mock_client(mock_device_info) -> APIClient: mock_client.device_info = AsyncMock(return_value=mock_device_info) mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() + mock_client.subscribe_logs = Mock() mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.address = "127.0.0.1" mock_client.api_version = APIVersion(99, 99) @@ -222,6 +228,7 @@ class MockESPHomeDevice: ] | None ) + self.on_log_message: Callable[[SubscribeLogsResponse], None] self.device_info = device_info def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: @@ -250,6 +257,16 @@ class MockESPHomeDevice: """Mock disconnecting.""" await self.on_disconnect(expected_disconnect) + def set_on_log_message( + self, on_log_message: Callable[[SubscribeLogsResponse], None] + ) -> None: + """Set the log message callback.""" + self.on_log_message = on_log_message + + def mock_on_log_message(self, log_message: SubscribeLogsResponse) -> None: + """Mock on log message.""" + self.on_log_message(log_message) + def set_on_connect(self, on_connect: Callable[[], None]) -> None: """Set the connect callback.""" self.on_connect = on_connect @@ -413,6 +430,12 @@ async def _mock_generic_device_entry( on_state_sub, on_state_request ) + def _subscribe_logs( + on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel + ) -> None: + """Subscribe to log messages.""" + mock_device.set_on_log_message(on_log_message) + def _subscribe_voice_assistant( *, handle_start: Callable[ @@ -453,6 +476,7 @@ async def _mock_generic_device_entry( mock_client.subscribe_states = _subscribe_states mock_client.subscribe_service_calls = _subscribe_service_calls mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states + mock_client.subscribe_logs = _subscribe_logs try_connect_done = Event() diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 65dab4c516f..afca6f76b43 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, CONF_NOISE_PSK, + CONF_SUBSCRIBE_LOGS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) @@ -1295,14 +1296,57 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["step_id"] == "encryption_key" -@pytest.mark.parametrize("option_value", [True, False]) -async def test_option_flow( +async def test_option_flow_allow_service_calls( hass: HomeAssistant, - option_value: bool, mock_client: APIClient, mock_generic_device_entry, ) -> None: - """Test config flow options.""" + """Test config flow options for allow service calls.""" + entry = await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + with patch( + "homeassistant.components.esphome.async_setup_entry", return_value=True + ) as mock_reload: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_ALLOW_SERVICE_CALLS: True, CONF_SUBSCRIBE_LOGS: False}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: True, + CONF_SUBSCRIBE_LOGS: False, + } + assert len(mock_reload.mock_calls) == 1 + + +async def test_option_flow_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test config flow options with subscribe logs.""" entry = await mock_generic_device_entry( mock_client=mock_client, entity_info=[], @@ -1315,7 +1359,8 @@ async def test_option_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert result["data_schema"]({}) == { - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, } with patch( @@ -1323,15 +1368,16 @@ async def test_option_flow( ) as mock_reload: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ - CONF_ALLOW_SERVICE_CALLS: option_value, - }, + user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: True}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value} - assert len(mock_reload.mock_calls) == int(option_value) + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: False, + CONF_SUBSCRIBE_LOGS: True, + } + assert len(mock_reload.mock_calls) == 1 @pytest.mark.usefixtures("mock_zeroconf") diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 7db1427d975..cf9d4a6f217 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,7 +2,8 @@ import asyncio from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock, call +import logging +from unittest.mock import AsyncMock, Mock, call from aioesphomeapi import ( APIClient, @@ -13,6 +14,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, + LogLevel, RequiresEncryptionAPIError, UserService, UserServiceArg, @@ -24,6 +26,7 @@ from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, + CONF_SUBSCRIBE_LOGS, DOMAIN, STABLE_BLE_VERSION_STR, ) @@ -44,6 +47,63 @@ from .conftest import MockESPHomeDevice from tests.common import MockConfigEntry, async_capture_events, async_mock_service +async def test_esphome_device_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test configuring a device to subscribe to logs.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fe80::1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_SUBSCRIBE_LOGS: True}, + ) + entry.add_to_hass(hass) + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + entity_info=[], + user_service=[], + device_info={}, + states=[], + ) + await hass.async_block_till_done() + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text + + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text + + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text + + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text + + async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, mock_client: APIClient, From 3230e741e9325253aac0dd3254fed68b4b8302ef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Feb 2025 22:49:41 +0100 Subject: [PATCH 1264/1435] Remove not used constants in smhi (#139298) --- homeassistant/components/smhi/weather.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index d2e31990012..5faef04e03d 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import timedelta from typing import Any, Final from pysmhi import SMHIForecast @@ -80,12 +79,6 @@ CONDITION_MAP = { for cond_code in cond_codes } -TIMEOUT = 10 -# 5 minutes between retrying connect to API again -RETRY_TIMEOUT = 5 * 60 - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) - async def async_setup_entry( hass: HomeAssistant, From 7bc0c1b9121ec6eb078e43c99680d045b670655a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 25 Feb 2025 22:52:44 +0100 Subject: [PATCH 1265/1435] Bump `aioshelly` to version `13.0.0` (#139294) * Bump aioshelly to version 13.0.0 * MODEL_BLU_GATEWAY_GEN3 -> MODEL_BLU_GATEWAY_G3 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_binary_sensor.py | 4 ++-- tests/components/shelly/test_climate.py | 8 ++++---- tests/components/shelly/test_number.py | 8 ++++---- tests/components/shelly/test_sensor.py | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c8073d6dbc2..ec08a005995 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.4.2"], + "requirements": ["aioshelly==13.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 7caab6809ba..4949a9fc4a9 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.4.2 +aioshelly==13.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ca116b3c24..17a6f6a6f56 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.4.2 +aioshelly==13.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 7f2d07b1ccc..1e7c54320e8 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3, MODEL_MOTION +from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -486,7 +486,7 @@ async def test_blu_trv_binary_sensor_entity( snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV binary sensor entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("calibration",): entity_id = f"{BINARY_SENSOR_DOMAIN}.trv_name_{entity}" diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 5ad298c15a1..040d67cb9c4 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, - MODEL_BLU_GATEWAY_GEN3, + MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, ) @@ -782,7 +782,7 @@ async def test_blu_trv_climate_set_temperature( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 @@ -820,7 +820,7 @@ async def test_blu_trv_climate_disabled( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 @@ -842,7 +842,7 @@ async def test_blu_trv_climate_hvac_action( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index b1b65d99ab5..6bddd1eeb23 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from syrupy import SnapshotAssertion @@ -405,7 +405,7 @@ async def test_blu_trv_number_entity( # disable automatic temperature control in the device monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("external_temperature", "valve_position"): entity_id = f"{NUMBER_DOMAIN}.trv_name_{entity}" @@ -421,7 +421,7 @@ async def test_blu_trv_ext_temp_set_value( hass: HomeAssistant, mock_blu_trv: Mock ) -> None: """Test the set value action for BLU TRV External Temperature number entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" @@ -461,7 +461,7 @@ async def test_blu_trv_valve_pos_set_value( # 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) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index ef7771e53ba..d0fec65c7de 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -1416,7 +1416,7 @@ async def test_blu_trv_sensor_entity( snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV sensor entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("battery", "signal_strength", "valve_position"): entity_id = f"{SENSOR_DOMAIN}.trv_name_{entity}" From 622be70fee42215fb67b7ac33998861808c81f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 25 Feb 2025 22:02:49 +0000 Subject: [PATCH 1266/1435] Remove timeout from vscode test launch configuration (#139288) --- .vscode/launch.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 15cdb9fb625..459a9e6acc5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,7 +38,6 @@ "module": "pytest", "justMyCode": false, "args": [ - "--timeout=10", "--picked" ], }, From 8644fb188761fbf50d791fb8c3707b16335893c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 23:05:52 +0100 Subject: [PATCH 1267/1435] Add missing Home Connect context at event listener registration for appliance options (#139292) * Add missing context at event listener registration for appliance options * Add tests --- .../components/home_connect/common.py | 35 ++--- tests/components/home_connect/test_entity.py | 121 ++++++++++++++++++ 2 files changed, 141 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index a9f48eea5ba..f52b59bc213 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -72,22 +72,27 @@ def _handle_paired_or_connected_appliance( for entity in get_option_entities_for_appliance(entry, appliance) if entity.unique_id not in known_entity_unique_ids ) - changed_options_listener_remove_callback = ( - entry.runtime_data.async_add_listener( - partial( - _create_option_entities, - entry, - appliance, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + (appliance.info.ha_id, event_key), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance.info.ha_id].append( - changed_options_listener_remove_callback - ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index 272fc21ba62..f173cda0b0c 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfPrograms, Event, EventKey, @@ -233,6 +234,126 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "option_key", "option_entity_id"), + [ + ( + "Dishwasher", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "switch.dishwasher_half_load", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval_after_appliance_connection( + event_key: EventKey, + appliance_ha_id: str, + option_key: OptionKey, + option_entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + array_of_home_appliances = client.get_home_appliances.return_value + + async def get_home_appliances_with_options_mock() -> ArrayOfHomeAppliances: + return ArrayOfHomeAppliances( + [ + appliance + for appliance in array_of_home_appliances.homeappliances + if appliance.ha_id != appliance_ha_id + ] + ) + + client.get_home_appliances = AsyncMock( + side_effect=get_home_appliances_with_options_mock + ) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert not hass.states.get(option_entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + raw_key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED.value, + timestamp=0, + level="", + handling="", + value="", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert not hass.states.get(option_entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ), + ], + ) + ) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(option_entity_id) + + @pytest.mark.parametrize( ( "set_active_program_option_side_effect", From 412ceca6f723f2187c583d58cb225f394baa0adf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:22:02 +0100 Subject: [PATCH 1268/1435] Sort common translation strings (#139300) sort common strings --- homeassistant/strings.json | 238 ++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index f423c3bf59c..29b7db7a011 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,13 +1,101 @@ { "common": { - "generic": { - "model": "Model", - "ui_managed": "Managed via UI" + "action": { + "close": "Close", + "connect": "Connect", + "disable": "Disable", + "disconnect": "Disconnect", + "enable": "Enable", + "open": "Open", + "pause": "Pause", + "reload": "Reload", + "restart": "Restart", + "start": "Start", + "stop": "Stop", + "toggle": "Toggle", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "config_flow": { + "abort": { + "already_configured_account": "Account is already configured", + "already_configured_device": "Device is already configured", + "already_configured_location": "Location is already configured", + "already_configured_service": "Service is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cloud_not_connected": "Not connected to Home Assistant Cloud.", + "no_devices_found": "No devices found on the network", + "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", + "oauth2_error": "Received invalid token data.", + "oauth2_failed": "Error while obtaining access token.", + "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth2_missing_credentials": "The integration requires application credentials.", + "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth2_timeout": "Timeout resolving OAuth token.", + "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", + "oauth2_user_rejected_authorize": "Account linking rejected: {error}", + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Re-configuration was successful", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", + "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages." + }, + "create_entry": { + "authenticated": "Successfully authenticated" + }, + "data": { + "access_token": "Access token", + "api_key": "API key", + "api_token": "API token", + "device": "Device", + "elevation": "Elevation", + "email": "Email", + "host": "Host", + "ip": "IP address", + "language": "Language", + "latitude": "Latitude", + "llm_hass_api": "Control Home Assistant", + "location": "Location", + "longitude": "Longitude", + "mode": "Mode", + "name": "Name", + "password": "Password", + "path": "Path", + "pin": "PIN code", + "port": "Port", + "ssl": "Uses an SSL certificate", + "url": "URL", + "usb_path": "USB device path", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + }, + "description": { + "confirm_setup": "Do you want to start setup?" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_access_token": "Invalid access token", + "invalid_api_key": "Invalid API key", + "invalid_auth": "Invalid authentication", + "invalid_host": "Invalid hostname or IP address", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "title": { + "oauth2_pick_implementation": "Pick authentication method", + "reauth": "Authentication expired for {name}", + "via_hassio_addon": "{name} via Home Assistant add-on" + } }, "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" }, "extra_fields": { "above": "Above", @@ -19,30 +107,35 @@ }, "trigger_type": { "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" - }, - "action_type": { - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } }, - "action": { - "connect": "Connect", - "disconnect": "Disconnect", - "enable": "Enable", - "disable": "Disable", + "generic": { + "model": "Model", + "ui_managed": "Managed via UI" + }, + "state": { + "active": "Active", + "charging": "Charging", + "closed": "Closed", + "connected": "Connected", + "disabled": "Disabled", + "discharging": "Discharging", + "disconnected": "Disconnected", + "enabled": "Enabled", + "home": "Home", + "idle": "Idle", + "locked": "Locked", + "no": "No", + "not_home": "Away", + "off": "Off", + "on": "On", "open": "Open", - "close": "Close", - "reload": "Reload", - "restart": "Restart", - "start": "Start", - "stop": "Stop", - "pause": "Pause", - "turn_on": "Turn on", - "turn_off": "Turn off", - "toggle": "Toggle" + "paused": "Paused", + "standby": "Standby", + "unlocked": "Unlocked", + "yes": "Yes" }, "time": { "monday": "Monday", @@ -52,99 +145,6 @@ "friday": "Friday", "saturday": "Saturday", "sunday": "Sunday" - }, - "state": { - "off": "Off", - "on": "On", - "yes": "Yes", - "no": "No", - "open": "Open", - "closed": "Closed", - "enabled": "Enabled", - "disabled": "Disabled", - "connected": "Connected", - "disconnected": "Disconnected", - "locked": "Locked", - "unlocked": "Unlocked", - "active": "Active", - "idle": "Idle", - "standby": "Standby", - "paused": "Paused", - "home": "Home", - "not_home": "Away", - "charging": "Charging", - "discharging": "Discharging" - }, - "config_flow": { - "title": { - "oauth2_pick_implementation": "Pick authentication method", - "reauth": "Authentication expired for {name}", - "via_hassio_addon": "{name} via Home Assistant add-on" - }, - "description": { - "confirm_setup": "Do you want to start setup?" - }, - "data": { - "device": "Device", - "name": "Name", - "email": "Email", - "username": "Username", - "password": "Password", - "host": "Host", - "ip": "IP address", - "port": "Port", - "url": "URL", - "usb_path": "USB device path", - "access_token": "Access token", - "api_key": "API key", - "api_token": "API token", - "llm_hass_api": "Control Home Assistant", - "ssl": "Uses an SSL certificate", - "verify_ssl": "Verify SSL certificate", - "elevation": "Elevation", - "longitude": "Longitude", - "latitude": "Latitude", - "location": "Location", - "pin": "PIN code", - "mode": "Mode", - "path": "Path", - "language": "Language" - }, - "create_entry": { - "authenticated": "Successfully authenticated" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_access_token": "Invalid access token", - "invalid_api_key": "Invalid API key", - "invalid_auth": "Invalid authentication", - "invalid_host": "Invalid hostname or IP address", - "unknown": "Unexpected error", - "timeout_connect": "Timeout establishing connection" - }, - "abort": { - "single_instance_allowed": "Already configured. Only a single configuration possible.", - "already_configured_account": "Account is already configured", - "already_configured_device": "Device is already configured", - "already_configured_location": "Location is already configured", - "already_configured_service": "Service is already configured", - "already_in_progress": "Configuration flow is already in progress", - "no_devices_found": "No devices found on the network", - "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.", - "oauth2_error": "Received invalid token data.", - "oauth2_timeout": "Timeout resolving OAuth token.", - "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", - "oauth2_missing_credentials": "The integration requires application credentials.", - "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", - "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "oauth2_user_rejected_authorize": "Account linking rejected: {error}", - "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", - "oauth2_failed": "Error while obtaining access token.", - "reauth_successful": "Re-authentication was successful", - "reconfigure_successful": "Re-configuration was successful", - "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", - "cloud_not_connected": "Not connected to Home Assistant Cloud." - } } } } From bd306abace66a43cd2c42c3be7cdfecc7a6962cf Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:55:53 +0000 Subject: [PATCH 1269/1435] Add album artist media browser category to Squeezebox (#139210) --- homeassistant/components/squeezebox/browse_media.py | 4 ++++ tests/components/squeezebox/conftest.py | 1 + tests/components/squeezebox/test_media_browser.py | 1 + 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index e12d2aa8844..6bc1d2380cf 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -29,6 +29,7 @@ LIBRARY = [ "Playlists", "Genres", "New Music", + "Album Artists", "Apps", "Radios", ] @@ -41,6 +42,7 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "Playlists": "playlists", "Genres": "genres", "New Music": "new music", + "Album Artists": "album artists", MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.TRACK: "title", @@ -71,6 +73,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = "Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, "Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, "New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, @@ -98,6 +101,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ "Radios": MediaClass.APP, "App": None, # can only be determined after inspecting the item "New Music": MediaType.ALBUM, + "Album Artists": MediaType.ARTIST, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, } diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index cb77495e818..9ca750808c5 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -132,6 +132,7 @@ async def mock_async_browse( child_types = { "favorites": "favorites", "new music": "album", + "album artists": "artists", "albums": "album", "album": "track", "genres": "genre", diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index f00ea1754fc..7b11ef30a87 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -77,6 +77,7 @@ async def test_async_browse_media_root( ("Playlists", 4), ("Genres", 4), ("New Music", 4), + ("Album Artists", 4), ("Apps", 3), ("Radios", 3), ], From 3ff04d6d049cf8ff65eddfbf87b7e65b5d8aecfb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 02:14:58 +0000 Subject: [PATCH 1270/1435] Bump aioesphomeapi to 29.2.0 (#139309) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 403da9286ab..b59dd544c49 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.1.1", + "aioesphomeapi==29.2.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 4949a9fc4a9..3a7fe746411 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.1.1 +aioesphomeapi==29.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17a6f6a6f56..f01c344b3c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.1.1 +aioesphomeapi==29.2.0 # homeassistant.components.flo aioflo==2021.11.0 From b1865de58f99ebe77c9e1d35c6cf72c7fd194e57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:13:25 +0100 Subject: [PATCH 1271/1435] Bump actions/download-artifact from 4.1.8 to 4.1.9 (#139317) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.8 to 4.1.9. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.1.8...v4.1.9) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- .github/workflows/ci.yaml | 6 +++--- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 68581c58d24..7867e635f51 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2aead92791a..8745ab63470 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -942,7 +942,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: pytest_buckets - name: Compile English translations @@ -1271,7 +1271,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1410,7 +1410,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: coverage-* - name: Upload coverage to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 743ae869ab9..7c02c8d97cd 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_all_wheels From 4530fe4bf70bc9ce7b842392bb20c00d01119bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:48:25 +0100 Subject: [PATCH 1272/1435] Bump home-assistant/builder from 2024.08.2 to 2025.02.0 (#139316) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 7867e635f51..0ad4c510a55 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2024.08.2 + uses: home-assistant/builder@2025.02.0 with: args: | $BUILD_ARGS \ @@ -263,7 +263,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2024.08.2 + uses: home-assistant/builder@2025.02.0 with: args: | $BUILD_ARGS \ From eb26a2124bf4e2ca55dcd635ade83ea4cf00e5d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 07:58:13 +0000 Subject: [PATCH 1273/1435] Adjust remote ESPHome log subscription level on logging change (#139308) --- homeassistant/components/esphome/manager.py | 53 +++++++++++++++++---- tests/components/esphome/conftest.py | 5 +- tests/components/esphome/test_manager.py | 32 +++++++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c73268de747..e32bb7d6ded 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -35,6 +35,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import ( + CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant, @@ -95,6 +96,14 @@ LOG_LEVEL_TO_LOGGER = { LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG, LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG, } +LOGGER_TO_LOG_LEVEL = { + logging.NOTSET: LogLevel.LOG_LEVEL_VERY_VERBOSE, + logging.DEBUG: LogLevel.LOG_LEVEL_VERY_VERBOSE, + logging.INFO: LogLevel.LOG_LEVEL_CONFIG, + logging.WARNING: LogLevel.LOG_LEVEL_WARN, + logging.ERROR: LogLevel.LOG_LEVEL_ERROR, + logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR, +} # 7-bit and 8-bit C1 ANSI sequences # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python ANSI_ESCAPE_78BIT = re.compile( @@ -161,6 +170,8 @@ class ESPHomeManager: """Class to manage an ESPHome connection.""" __slots__ = ( + "_cancel_subscribe_logs", + "_log_level", "cli", "device_id", "domain_data", @@ -194,6 +205,8 @@ class ESPHomeManager: self.reconnect_logic: ReconnectLogic | None = None self.zeroconf_instance = zeroconf_instance self.entry_data = entry.runtime_data + self._cancel_subscribe_logs: CALLBACK_TYPE | None = None + self._log_level = LogLevel.LOG_LEVEL_NONE async def on_stop(self, event: Event) -> None: """Cleanup the socket client on HA close.""" @@ -368,15 +381,31 @@ class ESPHomeManager: def _async_on_log(self, msg: SubscribeLogsResponse) -> None: """Handle a log message from the API.""" - logger_level = LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG) - if _LOGGER.isEnabledFor(logger_level): - log: bytes = msg.message - _LOGGER.log( - logger_level, - "%s: %s", - self.entry.title, - ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), - ) + log: bytes = msg.message + _LOGGER.log( + LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG), + "%s: %s", + self.entry.title, + ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), + ) + + @callback + def _async_get_equivalent_log_level(self) -> LogLevel: + """Get the equivalent ESPHome log level for the current logger.""" + return LOGGER_TO_LOG_LEVEL.get( + _LOGGER.getEffectiveLevel(), LogLevel.LOG_LEVEL_VERY_VERBOSE + ) + + @callback + def _async_subscribe_logs(self, log_level: LogLevel) -> None: + """Subscribe to logs.""" + if self._cancel_subscribe_logs is not None: + self._cancel_subscribe_logs() + self._cancel_subscribe_logs = None + self._log_level = log_level + self._cancel_subscribe_logs = self.cli.subscribe_logs( + self._async_on_log, self._log_level + ) async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" @@ -390,7 +419,7 @@ class ESPHomeManager: stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): - cli.subscribe_logs(self._async_on_log, LogLevel.LOG_LEVEL_VERY_VERBOSE) + self._async_subscribe_logs(self._async_get_equivalent_log_level()) results = await asyncio.gather( create_eager_task(cli.device_info()), create_eager_task(cli.list_entities_services()), @@ -542,6 +571,10 @@ class ESPHomeManager: def _async_handle_logging_changed(self, _event: Event) -> None: """Handle when the logging level changes.""" self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG)) + if self.entry.options.get(CONF_SUBSCRIBE_LOGS) and self._log_level != ( + new_log_level := self._async_get_equivalent_log_level() + ): + self._async_subscribe_logs(new_log_level) async def async_start(self) -> None: """Start the esphome connection manager.""" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 07f6c6ea697..dc6195bfe1f 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -230,6 +230,7 @@ class MockESPHomeDevice: ) self.on_log_message: Callable[[SubscribeLogsResponse], None] self.device_info = device_info + self.current_log_level = LogLevel.LOG_LEVEL_NONE def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """Set the state callback.""" @@ -432,9 +433,11 @@ async def _mock_generic_device_entry( def _subscribe_logs( on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel - ) -> None: + ) -> Callable[[], None]: """Subscribe to log messages.""" mock_device.set_on_log_message(on_log_message) + mock_device.current_log_level = log_level + return lambda: None def _subscribe_voice_assistant( *, diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index cf9d4a6f217..b805b065d5a 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -57,6 +57,7 @@ async def test_esphome_device_subscribe_logs( caplog: pytest.LogCaptureFixture, ) -> None: """Test configuring a device to subscribe to logs.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) entry = MockConfigEntry( domain=DOMAIN, data={ @@ -76,6 +77,15 @@ async def test_esphome_device_subscribe_logs( states=[], ) await hass.async_block_till_done() + + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "DEBUG"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + caplog.set_level(logging.DEBUG) device.mock_on_log_message( Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") @@ -103,6 +113,28 @@ async def test_esphome_device_subscribe_logs( await hass.async_block_till_done() assert "test_debug_log_message" in caplog.text + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "WARNING"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "ERROR"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "INFO"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, From cab6ec0363824ce78932a7b711ed1d3513d7946a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 09:02:17 +0100 Subject: [PATCH 1274/1435] Fix homeassistant/expose_entity/list (#138872) Co-authored-by: Paulus Schoutsen --- .../homeassistant/exposed_entities.py | 11 +++--- .../homeassistant/test_exposed_entities.py | 34 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 7bd9f9ab7bc..b7e420dedde 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -437,18 +437,21 @@ def ws_expose_entity( def ws_list_exposed_entities( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Expose an entity to an assistant.""" + """List entities which are exposed to assistants.""" result: dict[str, Any] = {} exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] entity_registry = er.async_get(hass) for entity_id in chain(exposed_entities.entities, entity_registry.entities): - result[entity_id] = {} + exposed_to = {} entity_settings = async_get_entity_settings(hass, entity_id) for assistant, settings in entity_settings.items(): - if "should_expose" not in settings: + if "should_expose" not in settings or not settings["should_expose"]: continue - result[entity_id][assistant] = settings["should_expose"] + exposed_to[assistant] = True + if not exposed_to: + continue + result[entity_id] = exposed_to connection.send_result(msg["id"], {"exposed_entities": result}) diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 1f1955c2f82..ec87672e75c 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -497,28 +497,48 @@ async def test_list_exposed_entities( entry1 = entity_registry.async_get_or_create("test", "test", "unique1") entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + entity_registry.async_get_or_create("test", "test", "unique3") # Set options for registered entities await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [entry1.entity_id, entry2.entity_id], + "entity_ids": [entry1.entity_id], "should_expose": True, } ) response = await ws_client.receive_json() assert response["success"] + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry2.entity_id], + "should_expose": False, + } + ) + response = await ws_client.receive_json() + assert response["success"] + # Set options for entities not in the entity registry await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [ - "test.test", - "test.test2", - ], + "entity_ids": ["test.test"], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": ["test.test2"], "should_expose": False, } ) @@ -531,10 +551,8 @@ async def test_list_exposed_entities( assert response["success"] assert response["result"] == { "exposed_entities": { - "test.test": {"cloud.alexa": False, "cloud.google_assistant": False}, - "test.test2": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test": {"cloud.alexa": True, "cloud.google_assistant": True}, "test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True}, - "test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True}, }, } From d15f9edc5709428f79b59daa17a8df9df7d57ee9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Feb 2025 11:51:35 +0100 Subject: [PATCH 1275/1435] Bump `accuweather` to version `4.1.0` (#139320) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 75f4a265b5f..5a019ef968e 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.0.0"], + "requirements": ["accuweather==4.1.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 3a7fe746411..9569e134bc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f01c344b3c7..ab22b808f92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 From 861ba0ee5e61004c900b1a0bc3bc759e216cfd37 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 26 Feb 2025 11:52:57 +0100 Subject: [PATCH 1276/1435] Bump ZHA to 0.0.50 (#139318) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 129 +++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 54de60b8669..25e4de77a32 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.49"], + "requirements": ["zha==0.0.50"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 2007adca0da..38f55fb550d 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1044,6 +1044,63 @@ }, "valve_duration": { "name": "Irrigation duration" + }, + "down_movement": { + "name": "Down movement" + }, + "sustain_time": { + "name": "Sustain time" + }, + "up_movement": { + "name": "Up movement" + }, + "large_motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "large_motion_detection_distance": { + "name": "Motion detection distance" + }, + "medium_motion_detection_distance": { + "name": "Medium motion detection distance" + }, + "medium_motion_detection_sensitivity": { + "name": "Medium motion detection sensitivity" + }, + "small_motion_detection_distance": { + "name": "Small motion detection distance" + }, + "small_motion_detection_sensitivity": { + "name": "Small motion detection sensitivity" + }, + "static_detection_sensitivity": { + "name": "Static detection sensitivity" + }, + "static_detection_distance": { + "name": "Static detection distance" + }, + "motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "holiday_temperature": { + "name": "Holiday temperature" + }, + "boost_time": { + "name": "Boost time" + }, + "antifrost_temperature": { + "name": "Antifrost temperature" + }, + "eco_temperature": { + "name": "Eco temperature" + }, + "comfort_temperature": { + "name": "Comfort temperature" + }, + "valve_state_auto_shutdown": { + "name": "Valve state auto shutdown" + }, + "shutdown_timer": { + "name": "Shutdown timer" } }, "select": { @@ -1235,6 +1292,33 @@ }, "eco_mode": { "name": "Eco mode" + }, + "mode": { + "name": "Mode" + }, + "reverse": { + "name": "Reverse" + }, + "motion_state": { + "name": "Motion state" + }, + "motion_detection_mode": { + "name": "Motion detection mode" + }, + "screen_orientation": { + "name": "Screen orientation" + }, + "motor_thrust": { + "name": "Motor thrust" + }, + "display_brightness": { + "name": "Display brightness" + }, + "display_orientation": { + "name": "Display orientation" + }, + "hysteresis_mode": { + "name": "Hysteresis mode" } }, "sensor": { @@ -1561,6 +1645,27 @@ }, "error_status": { "name": "Error status" + }, + "brightness_level": { + "name": "Brightness level" + }, + "average_light_intensity_20mins": { + "name": "Average light intensity last 20 min" + }, + "todays_max_light_intensity": { + "name": "Today's max light intensity" + }, + "fault_code": { + "name": "Fault code" + }, + "water_flow": { + "name": "Water flow" + }, + "remaining_watering_time": { + "name": "Remaining watering time" + }, + "last_watering_duration": { + "name": "Last watering duration" } }, "switch": { @@ -1746,6 +1851,30 @@ }, "total_flow_reset_switch": { "name": "Total flow reset switch" + }, + "touch_control": { + "name": "Touch control" + }, + "sound_enabled": { + "name": "Sound enabled" + }, + "invert_relay": { + "name": "Invert relay" + }, + "boost_heating": { + "name": "Boost heating" + }, + "holiday_mode": { + "name": "Holiday mode" + }, + "heating_stop": { + "name": "Heating stop" + }, + "schedule_mode": { + "name": "Schedule mode" + }, + "auto_clean": { + "name": "Auto clean" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 9569e134bc2..c4570f25195 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3152,7 +3152,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.49 +zha==0.0.50 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab22b808f92..6b30a0c0867 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2541,7 +2541,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.49 +zha==0.0.50 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 From 5895245a31a8d60a6fcb2ca93225609ce288184a Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Wed, 26 Feb 2025 05:57:54 -0500 Subject: [PATCH 1277/1435] Bump pytechnove to 2.0.0 (#139314) --- homeassistant/components/technove/manifest.json | 2 +- homeassistant/components/technove/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/technove/snapshots/test_diagnostics.ambr | 2 +- tests/components/technove/snapshots/test_sensor.ambr | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index 722aa4004e1..746c2280aaa 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==1.3.1"], + "requirements": ["python-technove==2.0.0"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 9976f0b3c59..05260845a03 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -70,7 +70,7 @@ "plugged_waiting": "Plugged, waiting", "plugged_charging": "Plugged, charging", "out_of_activation_period": "Out of activation period", - "high_charge_period": "High charge period" + "high_tariff_period": "High tariff period" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index c4570f25195..766addab2b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2479,7 +2479,7 @@ python-songpal==0.16.2 python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b30a0c0867..ca35a30f50b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2012,7 +2012,7 @@ python-songpal==0.16.2 python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 diff --git a/tests/components/technove/snapshots/test_diagnostics.ambr b/tests/components/technove/snapshots/test_diagnostics.ambr index 175e8f2022a..e16c51a2e98 100644 --- a/tests/components/technove/snapshots/test_diagnostics.ambr +++ b/tests/components/technove/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ 'current': 23.75, 'energy_session': 12.34, 'energy_total': 1234, - 'high_charge_period_active': False, + 'high_tariff_period_active': False, 'in_sharing_mode': False, 'is_battery_protected': False, 'is_session_active': True, diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr index dec671b0f34..aaec5667e55 100644 --- a/tests/components/technove/snapshots/test_sensor.ambr +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -322,7 +322,7 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'config_entry_id': , @@ -363,7 +363,7 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'context': , From fe396cdf4b0f6e29aa38d2b235485999eb50195d Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Wed, 26 Feb 2025 02:59:13 -0800 Subject: [PATCH 1278/1435] Update python-smarttub dependency to 0.0.39 (#139313) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index d5102f14437..b8d81db0ea5 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "requirements": ["python-smarttub==0.0.38"] + "requirements": ["python-smarttub==0.0.39"] } diff --git a/requirements_all.txt b/requirements_all.txt index 766addab2b6..11d223a21f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-ripple-api==0.0.3 python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 # homeassistant.components.snoo python-snoo==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca35a30f50b..3d25b71b2a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-rabbitair==0.0.8 python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 # homeassistant.components.snoo python-snoo==0.6.0 From b82886a3e1b0edc5044d096ea4ff30810f9f8713 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 26 Feb 2025 15:25:59 +0300 Subject: [PATCH 1279/1435] Fix anthropic blocking call (#139299) --- homeassistant/components/anthropic/__init__.py | 6 +++++- homeassistant/components/anthropic/config_flow.py | 5 ++++- tests/components/anthropic/test_conversation.py | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index aa6cf509fa1..84c9054b476 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from functools import partial + import anthropic from homeassistant.config_entries import ConfigEntry @@ -20,7 +22,9 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: """Set up Anthropic from a config entry.""" - client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) + ) try: await client.messages.create( model="claude-3-haiku-20240307", diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index fa43a3c4bcc..63a70f31fea 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import partial import logging from types import MappingProxyType from typing import Any @@ -59,7 +60,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY]) + ) await client.messages.create( model="claude-3-haiku-20240307", max_tokens=1, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index bda9ca32b34..a35df281fb6 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -488,6 +488,7 @@ async def test_unknown_hass_api( CONF_LLM_HASS_API: "non-existing", }, ) + await hass.async_block_till_done() result = await conversation.async_converse( hass, "hello", "1234", Context(), agent_id="conversation.claude" From 4dca4a64b522f0ca8d454ddcfa7fe5329ef028ee Mon Sep 17 00:00:00 2001 From: Ben Bridts Date: Wed, 26 Feb 2025 13:26:12 +0100 Subject: [PATCH 1280/1435] Bump pybotvac to 0.0.26 (#139330) --- homeassistant/components/neato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index e4b471cb5ac..ef7cda52f19 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/neato", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.25"] + "requirements": ["pybotvac==0.0.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11d223a21f9..da1df50e3a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1843,7 +1843,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d25b71b2a8..815f42090a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1520,7 +1520,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 From 0f827fbf2238506f15771fa03985f1e3bbf48e79 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 26 Feb 2025 13:31:07 +0100 Subject: [PATCH 1281/1435] Bump stookwijzer==1.6.0 (#139332) --- homeassistant/components/stookwijzer/__init__.py | 6 ++---- homeassistant/components/stookwijzer/config_flow.py | 6 ++---- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index d8b9561bde9..a4a00e4d1b8 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,13 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(hass), + longitude, latitude = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not latitude or not longitude: + if not longitude or not latitude: ir.async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 32b4836763f..52283e4842d 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,12 +25,11 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(self.hass), + longitude, latitude = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if latitude and longitude: + if longitude and latitude: return self.async_create_entry( title="Stookwijzer", data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 8243b903e8d..9b4cea567be 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.4"] + "requirements": ["stookwijzer==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index da1df50e3a2..7a60530b12c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.6.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 815f42090a5..af549502560 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.6.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 3f7303e97f6..95a60e623a3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -71,8 +71,8 @@ def mock_stookwijzer() -> Generator[MagicMock]: ), ): stookwijzer_mock.async_transform_coordinates.return_value = ( - 200000.123456789, 450000.123456789, + 200000.123456789, ) client = stookwijzer_mock.return_value From ee01aa73b8290d25bc6f70fe28df92bcb8c3d9d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 13:44:09 +0100 Subject: [PATCH 1282/1435] Improve error message when failing to create backups (#139262) * Improve error message when failing to create backups * Check for expected error message in tests --- homeassistant/components/backup/manager.py | 17 ++- tests/components/backup/test_manager.py | 120 ++++++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index bd970d7708a..317de85b823 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1620,7 +1620,13 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Generate backup contents and return the size.""" if not tar_file_path: tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar" - make_backup_dir(tar_file_path.parent) + try: + make_backup_dir(tar_file_path.parent) + except OSError as err: + raise BackupReaderWriterError( + f"Failed to create dir {tar_file_path.parent}: " + f"{err} ({err.__class__.__name__})" + ) from err excludes = EXCLUDE_FROM_BACKUP if not database_included: @@ -1658,7 +1664,14 @@ class CoreBackupReaderWriter(BackupReaderWriter): file_filter=is_excluded_by_filter, arcname="data", ) - return (tar_file_path, tar_file_path.stat().st_size) + try: + stat_result = tar_file_path.stat() + except OSError as err: + raise BackupReaderWriterError( + f"Error getting size of {tar_file_path}: " + f"{err} ({err.__class__.__name__})" + ) from err + return (tar_file_path, stat_result.st_size) async def async_receive_backup( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 3c72929cfe0..6e626e63748 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1311,7 +1311,7 @@ async def test_initiate_backup_with_task_error( (1, None, 1, None, 1, None, 1, OSError("Boom!")), ], ) -async def test_initiate_backup_file_error( +async def test_initiate_backup_file_error_upload_to_agents( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -1325,7 +1325,7 @@ async def test_initiate_backup_file_error( unlink_call_count: int, unlink_exception: Exception | None, ) -> None: - """Test file error during generate backup.""" + """Test file error during generate backup, while uploading to agents.""" agent_ids = ["test.remote"] await setup_backup_integration(hass, remote_agents=["test.remote"]) @@ -1418,6 +1418,122 @@ async def test_initiate_backup_file_error( assert unlink_mock.call_count == unlink_call_count +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "mkdir_call_count", + "mkdir_exception", + "atomic_contents_add_call_count", + "atomic_contents_add_exception", + "stat_call_count", + "stat_exception", + "error_message", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None, "Failed to create dir"), + (1, None, 1, OSError("Boom!"), 0, None, "Boom!"), + (1, None, 1, None, 1, OSError("Boom!"), "Error getting size"), + ], +) +async def test_initiate_backup_file_error_create_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + caplog: pytest.LogCaptureFixture, + mkdir_call_count: int, + mkdir_exception: Exception | None, + atomic_contents_add_call_count: int, + atomic_contents_add_exception: Exception | None, + stat_call_count: int, + stat_exception: Exception | None, + error_message: str, +) -> None: + """Test file error during generate backup, while creating backup.""" + agent_ids = ["test.remote"] + + await setup_backup_integration(hass, remote_agents=["test.remote"]) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch( + "homeassistant.components.backup.manager.atomic_contents_add", + side_effect=atomic_contents_add_exception, + ) as atomic_contents_add_mock, + patch("pathlib.Path.mkdir", side_effect=mkdir_exception) as mkdir_mock, + patch("pathlib.Path.stat", side_effect=stat_exception) as stat_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert atomic_contents_add_mock.call_count == atomic_contents_add_call_count + assert mkdir_mock.call_count == mkdir_call_count + assert stat_mock.call_count == stat_call_count + + assert error_message in caplog.text + + def _mock_local_backup_agent(name: str) -> Mock: local_agent = mock_backup_agent(name) # This makes the local_agent pass isinstance checks for LocalBackupAgent From e591157e37407c117cad6909a8b36d23c6fc6582 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 26 Feb 2025 13:44:43 +0100 Subject: [PATCH 1283/1435] Add translations and icon for Twinkly select entity (#139336) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/twinkly/icons.json | 5 +++++ homeassistant/components/twinkly/select.py | 2 +- homeassistant/components/twinkly/strings.json | 16 ++++++++++++++++ .../twinkly/snapshots/test_select.ambr | 2 +- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/twinkly/icons.json b/homeassistant/components/twinkly/icons.json index 82c95aebce6..d57d54aa507 100644 --- a/homeassistant/components/twinkly/icons.json +++ b/homeassistant/components/twinkly/icons.json @@ -4,6 +4,11 @@ "light": { "default": "mdi:string-lights" } + }, + "select": { + "mode": { + "default": "mdi:cogs" + } } } } diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 86d9732b8cc..a5283b3f91d 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -29,7 +29,7 @@ async def async_setup_entry( class TwinklyModeSelect(TwinklyEntity, SelectEntity): """Twinkly Mode Selection.""" - _attr_name = "Mode" + _attr_translation_key = "mode" _attr_options = TWINKLY_MODES def __init__(self, coordinator: TwinklyCoordinator) -> None: diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index bbc3d67373d..c2e0efef92c 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -20,5 +20,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "select": { + "mode": { + "name": "Mode", + "state": { + "color": "Color", + "demo": "Demo", + "effect": "Effect", + "movie": "Uploaded effect", + "off": "[%key:common::state::off%]", + "playlist": "Playlist", + "rt": "Real time" + } + } + } } } diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr index 26edd4b731d..6700aecd1f2 100644 --- a/tests/components/twinkly/snapshots/test_select.ambr +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -38,7 +38,7 @@ 'platform': 'twinkly', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'mode', 'unique_id': '00:2d:13:3b:aa:bb_mode', 'unit_of_measurement': None, }) From 2bf592d8aa951977d500f3a66ca341ce058a5e2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 12:55:03 +0000 Subject: [PATCH 1284/1435] Bump recommended ESPHome Bluetooth proxy version to 2025.2.1 (#139196) --- homeassistant/components/esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index aabebad01b6..eb5f03c4495 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -13,7 +13,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False -STABLE_BLE_VERSION_STR = "2023.8.0" +STABLE_BLE_VERSION_STR = "2025.2.1" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 0c092f80c7ac95bc1bb696da62d380f69320e95e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 14:09:38 +0100 Subject: [PATCH 1285/1435] Add default_db_url flag to WS command recorder/info (#139333) --- homeassistant/components/recorder/__init__.py | 9 +++-- .../recorder/basic_websocket_api.py | 3 ++ .../components/recorder/test_websocket_api.py | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5a95ace92cb..7cb71e70f65 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -149,9 +149,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: commit_interval = conf[CONF_COMMIT_INTERVAL] db_max_retries = conf[CONF_DB_MAX_RETRIES] db_retry_wait = conf[CONF_DB_RETRY_WAIT] - db_url = conf.get(CONF_DB_URL) or DEFAULT_URL.format( - hass_config_path=hass.config.path(DEFAULT_DB_FILE) - ) + db_url = conf.get(CONF_DB_URL) or get_default_url(hass) exclude = conf[CONF_EXCLUDE] exclude_event_types: set[EventType[Any] | str] = set( exclude.get(CONF_EVENT_TYPES, []) @@ -200,3 +198,8 @@ async def _async_setup_integration_platform( instance.queue_task(AddRecorderPlatformTask(domain, platform)) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + +def get_default_url(hass: HomeAssistant) -> str: + """Return the default URL.""" + return DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index 258f6c63a9d..ce9aa452fae 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -10,6 +10,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import recorder as recorder_helper +from . import get_default_url from .util import get_instance @@ -34,6 +35,7 @@ async def ws_info( await hass.data[recorder_helper.DATA_RECORDER].db_connected instance = get_instance(hass) backlog = instance.backlog + db_in_default_location = instance.db_url == get_default_url(hass) migration_in_progress = instance.migration_in_progress migration_is_live = instance.migration_is_live recording = instance.recording @@ -44,6 +46,7 @@ async def ws_info( recorder_info = { "backlog": backlog, + "db_in_default_location": db_in_default_location, "max_backlog": max_backlog, "migration_in_progress": migration_in_progress, "migration_is_live": migration_is_live, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8f93264b682..a4e35bc8753 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2562,6 +2562,7 @@ async def test_recorder_info( assert response["success"] assert response["result"] == { "backlog": 0, + "db_in_default_location": False, # We never use the default URL in tests "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, @@ -2570,6 +2571,44 @@ async def test_recorder_info( } +@pytest.mark.parametrize( + ("db_url", "db_in_default_location"), + [ + ("sqlite:///{config_dir}/home-assistant_v2.db", True), + ("sqlite:///{config_dir}/custom.db", False), + ("mysql://root:root_password@127.0.0.1:3316/homeassistant-test", False), + ], +) +async def test_recorder_info_default_url( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + db_url: str, + db_in_default_location: bool, +) -> None: + """Test getting recorder status.""" + client = await hass_ws_client() + + # Ensure there are no queued events + await async_wait_recording_done(hass) + + with patch.object( + recorder_mock, "db_url", db_url.format(config_dir=hass.config.config_dir) + ): + await client.send_json_auto_id({"type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "backlog": 0, + "db_in_default_location": db_in_default_location, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } + + async def test_recorder_info_no_recorder( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2624,6 +2663,7 @@ async def test_recorder_info_wait_database_connect( assert response["success"] assert response["result"] == { "backlog": ANY, + "db_in_default_location": False, "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, From b676c2f61b1da5c42199c946da257d85cb5779b7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Feb 2025 14:24:19 +0100 Subject: [PATCH 1286/1435] Improve action descriptions of LIFX integration (#139329) Improve action description of lifx integration - fix sentence-casing on two action names - change "Kelvin" unit name to proper uppercase - reference 'Theme' and 'Palette' fields by their friendly names for matching translations - change paint_theme action description to match HA style --- homeassistant/components/lifx/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 39102d904d5..c407489d52d 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -66,7 +66,7 @@ } }, "set_state": { - "name": "Set State", + "name": "Set state", "description": "Sets a color/brightness and possibly turn the light on/off.", "fields": { "infrared": { @@ -209,11 +209,11 @@ }, "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect. Overrides the 'Theme' attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", - "description": "Predefined color theme to use for the effect. Overridden by the palette attribute." + "description": "Predefined color theme to use for the effect. Overridden by the 'Palette' attribute." }, "power_on": { "name": "Power on", @@ -243,7 +243,7 @@ }, "palette": { "name": "Palette", - "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect." + "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect." }, "power_on": { "name": "Power on", @@ -256,16 +256,16 @@ "description": "Stops a running effect." }, "paint_theme": { - "name": "Paint Theme", - "description": "Paint either a provided theme or custom palette across one or more LIFX lights.", + "name": "Paint theme", + "description": "Paints either a provided theme or custom palette across one or more LIFX lights.", "fields": { "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to paint across the target lights. Overrides the 'Theme' attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", - "description": "Predefined color theme to paint. Overridden by the palette attribute." + "description": "Predefined color theme to paint. Overridden by the 'Palette' attribute." }, "transition": { "name": "Transition", From bb9aba2a7dac8b54831781f3db8ccf6e094ea738 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 26 Feb 2025 14:48:18 +0100 Subject: [PATCH 1287/1435] Bump Music Assistant client to 1.1.1 (#139331) --- .../components/music_assistant/actions.py | 6 +++++- .../components/music_assistant/manifest.json | 2 +- .../components/music_assistant/media_browser.py | 11 +++++++++++ .../components/music_assistant/media_player.py | 4 +++- .../components/music_assistant/schemas.py | 16 ++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 32 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bcd33b7fd6c..bf9a1260362 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -48,6 +48,7 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient + from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from . import MusicAssistantConfigEntry @@ -173,6 +174,9 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "offset": offset, "order_by": order_by, } + library_result: ( + list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( **base_params, @@ -181,7 +185,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: elif media_type == MediaType.ARTIST: library_result = await mass.music.get_library_artists( **base_params, - album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY), + album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), ) elif media_type == MediaType.TRACK: library_result = await mass.music.get_library_tracks( diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index f5cdcf50673..fb8bb9c3ac2 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.8"], + "requirements": ["music-assistant-client==1.1.1"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index e65d6d4a975..a926e2a0595 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -166,6 +166,8 @@ async def build_playlist_items_listing( ) -> BrowseMedia: """Build Playlist items browse listing.""" playlist = await mass.music.get_item_by_uri(identifier) + if TYPE_CHECKING: + assert playlist.uri is not None return BrowseMedia( media_class=MediaClass.PLAYLIST, @@ -219,6 +221,9 @@ async def build_artist_items_listing( artist = await mass.music.get_item_by_uri(identifier) albums = await mass.music.get_artist_albums(artist.item_id, artist.provider) + if TYPE_CHECKING: + assert artist.uri is not None + return BrowseMedia( media_class=MediaType.ARTIST, media_content_id=artist.uri, @@ -267,6 +272,9 @@ async def build_album_items_listing( album = await mass.music.get_item_by_uri(identifier) tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + if TYPE_CHECKING: + assert album.uri is not None + return BrowseMedia( media_class=MediaType.ALBUM, media_content_id=album.uri, @@ -340,6 +348,9 @@ def build_item( title = item.name img_url = mass.get_media_item_image_url(item) + if TYPE_CHECKING: + assert item.uri is not None + return BrowseMedia( media_class=media_class or item.media_type.value, media_content_id=item.uri, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 5621b5eb562..bbbda095302 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -20,6 +20,7 @@ from music_assistant_models.enums import ( from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +from music_assistant_models.player_queue import PlayerQueue import voluptuous as vol from homeassistant.components import media_source @@ -78,7 +79,6 @@ from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player - from music_assistant_models.player_queue import PlayerQueue SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.PAUSE @@ -473,6 +473,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): album=album, media_type=MediaType(media_type) if media_type else None, ): + if TYPE_CHECKING: + assert item.uri is not None media_uris.append(item.uri) if not media_uris: diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index d8c4fe1649d..0954d1573e7 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -65,20 +65,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema( def media_item_dict_from_mass_item( mass: MusicAssistantClient, - item: MediaItemType | ItemMapping | None, -) -> dict[str, Any] | None: + item: MediaItemType | ItemMapping, +) -> dict[str, Any]: """Parse a Music Assistant MediaItem.""" - if not item: - return None - base = { + base: dict[str, Any] = { ATTR_MEDIA_TYPE: item.media_type, ATTR_URI: item.uri, ATTR_NAME: item.name, ATTR_VERSION: item.version, ATTR_IMAGE: mass.get_media_item_image_url(item), } + artists: list[ItemMapping] | None if artists := getattr(item, "artists", None): base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists] + album: ItemMapping | None if album := getattr(item, "album", None): base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album) return base @@ -151,7 +151,11 @@ def queue_item_dict_from_mass_item( ATTR_QUEUE_ITEM_ID: item.queue_item_id, ATTR_NAME: item.name, ATTR_DURATION: item.duration, - ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item), + ATTR_MEDIA_ITEM: ( + media_item_dict_from_mass_item(mass, item.media_item) + if item.media_item + else None + ), } if streamdetails := item.streamdetails: base[ATTR_STREAM_TITLE] = streamdetails.stream_title diff --git a/requirements_all.txt b/requirements_all.txt index 7a60530b12c..40df67dc93f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1447,7 +1447,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af549502560..029b770512e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1219,7 +1219,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 From bb120020a8e9bcdd1789275c5bc722dd3e7230ec Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:14:04 +0100 Subject: [PATCH 1288/1435] Refactor SmartThings (#137940) --- CODEOWNERS | 2 + .../components/smartthings/__init__.py | 478 +- .../smartthings/application_credentials.py | 64 + .../components/smartthings/binary_sensor.py | 162 +- .../components/smartthings/climate.py | 510 +- .../components/smartthings/config_flow.py | 313 +- homeassistant/components/smartthings/const.py | 64 +- homeassistant/components/smartthings/cover.py | 139 +- .../components/smartthings/entity.py | 107 +- homeassistant/components/smartthings/fan.py | 128 +- homeassistant/components/smartthings/light.py | 159 +- homeassistant/components/smartthings/lock.py | 42 +- .../components/smartthings/manifest.json | 9 +- homeassistant/components/smartthings/scene.py | 21 +- .../components/smartthings/sensor.py | 547 +- .../components/smartthings/smartapp.py | 545 -- .../components/smartthings/strings.json | 50 +- .../components/smartthings/switch.py | 59 +- .../generated/application_credentials.py | 1 + requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- tests/components/smartthings/__init__.py | 76 +- tests/components/smartthings/conftest.py | 462 +- .../aeotec_home_energy_meter_gen5.json | 31 + .../device_status/base_electric_meter.json | 21 + .../device_status/c2c_arlo_pro_3_switch.json | 82 + .../fixtures/device_status/c2c_shade.json | 50 + .../fixtures/device_status/centralite.json | 60 + .../device_status/contact_sensor.json | 66 + .../device_status/da_ac_rac_000001.json | 879 +++ .../device_status/da_ac_rac_01001.json | 731 +++ .../device_status/da_ks_microwave_0101x.json | 600 ++ .../device_status/da_ref_normal_000001.json | 727 +++ .../device_status/da_rvc_normal_000001.json | 274 + .../device_status/da_wm_dw_000001.json | 786 +++ .../device_status/da_wm_wd_000001.json | 719 +++ .../device_status/da_wm_wm_000001.json | 1243 +++++ .../fixtures/device_status/ecobee_sensor.json | 51 + .../device_status/ecobee_thermostat.json | 98 + .../fixtures/device_status/fake_fan.json | 31 + .../ge_in_wall_smart_dimmer.json | 23 + .../hue_color_temperature_bulb.json | 75 + .../device_status/hue_rgbw_color_bulb.json | 94 + .../fixtures/device_status/iphone.json | 12 + .../device_status/multipurpose_sensor.json | 79 + .../sensibo_airconditioner_1.json | 57 + .../fixtures/device_status/smart_plug.json | 43 + .../fixtures/device_status/sonos_player.json | 259 + .../device_status/vd_network_audio_002s.json | 164 + .../fixtures/device_status/vd_stv_2017_k.json | 266 + .../device_status/virtual_thermostat.json | 97 + .../fixtures/device_status/virtual_valve.json | 13 + .../device_status/virtual_water_sensor.json | 28 + .../yale_push_button_deadbolt_lock.json | 110 + .../aeotec_home_energy_meter_gen5.json | 70 + .../fixtures/devices/base_electric_meter.json | 62 + .../devices/c2c_arlo_pro_3_switch.json | 79 + .../fixtures/devices/c2c_shade.json | 59 + .../fixtures/devices/centralite.json | 67 + .../fixtures/devices/contact_sensor.json | 71 + .../fixtures/devices/da_ac_rac_000001.json | 311 ++ .../fixtures/devices/da_ac_rac_01001.json | 264 + .../devices/da_ks_microwave_0101x.json | 176 + .../devices/da_ref_normal_000001.json | 412 ++ .../devices/da_rvc_normal_000001.json | 119 + .../fixtures/devices/da_wm_dw_000001.json | 168 + .../fixtures/devices/da_wm_wd_000001.json | 204 + .../fixtures/devices/da_wm_wm_000001.json | 260 + .../fixtures/devices/ecobee_sensor.json | 64 + .../fixtures/devices/ecobee_thermostat.json | 80 + .../fixtures/devices/fake_fan.json | 50 + .../devices/ge_in_wall_smart_dimmer.json | 65 + .../devices/hue_color_temperature_bulb.json | 73 + .../fixtures/devices/hue_rgbw_color_bulb.json | 81 + .../smartthings/fixtures/devices/iphone.json | 41 + .../fixtures/devices/multipurpose_sensor.json | 78 + .../devices/sensibo_airconditioner_1.json | 64 + .../fixtures/devices/smart_plug.json | 59 + .../fixtures/devices/sonos_player.json | 82 + .../devices/vd_network_audio_002s.json | 109 + .../fixtures/devices/vd_stv_2017_k.json | 148 + .../fixtures/devices/virtual_thermostat.json | 69 + .../fixtures/devices/virtual_valve.json | 49 + .../devices/virtual_water_sensor.json | 53 + .../yale_push_button_deadbolt_lock.json | 67 + .../smartthings/fixtures/locations.json | 9 + .../smartthings/fixtures/scenes.json | 34 + .../snapshots/test_binary_sensor.ambr | 529 ++ .../smartthings/snapshots/test_climate.ambr | 356 ++ .../smartthings/snapshots/test_cover.ambr | 100 + .../smartthings/snapshots/test_fan.ambr | 67 + .../smartthings/snapshots/test_init.ambr | 1024 ++++ .../smartthings/snapshots/test_light.ambr | 267 + .../smartthings/snapshots/test_lock.ambr | 50 + .../smartthings/snapshots/test_scene.ambr | 101 + .../smartthings/snapshots/test_sensor.ambr | 4857 +++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 471 ++ .../smartthings/test_binary_sensor.py | 158 +- tests/components/smartthings/test_climate.py | 1382 ++--- .../smartthings/test_config_flow.py | 1179 ++-- tests/components/smartthings/test_cover.py | 369 +- tests/components/smartthings/test_fan.py | 521 +- tests/components/smartthings/test_init.py | 571 +- tests/components/smartthings/test_light.py | 561 +- tests/components/smartthings/test_lock.py | 174 +- tests/components/smartthings/test_scene.py | 65 +- tests/components/smartthings/test_sensor.py | 306 +- tests/components/smartthings/test_smartapp.py | 186 - tests/components/smartthings/test_switch.py | 166 +- 109 files changed, 22599 insertions(+), 6175 deletions(-) create mode 100644 homeassistant/components/smartthings/application_credentials.py delete mode 100644 homeassistant/components/smartthings/smartapp.py create mode 100644 tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json create mode 100644 tests/components/smartthings/fixtures/device_status/base_electric_meter.json create mode 100644 tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json create mode 100644 tests/components/smartthings/fixtures/device_status/c2c_shade.json create mode 100644 tests/components/smartthings/fixtures/device_status/centralite.json create mode 100644 tests/components/smartthings/fixtures/device_status/contact_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json create mode 100644 tests/components/smartthings/fixtures/device_status/fake_fan.json create mode 100644 tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json create mode 100644 tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json create mode 100644 tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json create mode 100644 tests/components/smartthings/fixtures/device_status/iphone.json create mode 100644 tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json create mode 100644 tests/components/smartthings/fixtures/device_status/smart_plug.json create mode 100644 tests/components/smartthings/fixtures/device_status/sonos_player.json create mode 100644 tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json create mode 100644 tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_thermostat.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_valve.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json create mode 100644 tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json create mode 100644 tests/components/smartthings/fixtures/devices/base_electric_meter.json create mode 100644 tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json create mode 100644 tests/components/smartthings/fixtures/devices/c2c_shade.json create mode 100644 tests/components/smartthings/fixtures/devices/centralite.json create mode 100644 tests/components/smartthings/fixtures/devices/contact_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/fake_fan.json create mode 100644 tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json create mode 100644 tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json create mode 100644 tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json create mode 100644 tests/components/smartthings/fixtures/devices/iphone.json create mode 100644 tests/components/smartthings/fixtures/devices/multipurpose_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json create mode 100644 tests/components/smartthings/fixtures/devices/smart_plug.json create mode 100644 tests/components/smartthings/fixtures/devices/sonos_player.json create mode 100644 tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json create mode 100644 tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_valve.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_water_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json create mode 100644 tests/components/smartthings/fixtures/locations.json create mode 100644 tests/components/smartthings/fixtures/scenes.json create mode 100644 tests/components/smartthings/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/smartthings/snapshots/test_climate.ambr create mode 100644 tests/components/smartthings/snapshots/test_cover.ambr create mode 100644 tests/components/smartthings/snapshots/test_fan.ambr create mode 100644 tests/components/smartthings/snapshots/test_init.ambr create mode 100644 tests/components/smartthings/snapshots/test_light.ambr create mode 100644 tests/components/smartthings/snapshots/test_lock.ambr create mode 100644 tests/components/smartthings/snapshots/test_scene.ambr create mode 100644 tests/components/smartthings/snapshots/test_sensor.ambr create mode 100644 tests/components/smartthings/snapshots/test_switch.ambr delete mode 100644 tests/components/smartthings/test_smartapp.py diff --git a/CODEOWNERS b/CODEOWNERS index 1052a58fe88..3366bfb0885 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1401,6 +1401,8 @@ build.json @home-assistant/supervisor /tests/components/smappee/ @bsmappee /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler +/homeassistant/components/smartthings/ @joostlek +/tests/components/smartthings/ @joostlek /homeassistant/components/smarttub/ @mdz /tests/components/smarttub/ @mdz /homeassistant/components/smarty/ @z0mbieprocess diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 2914851ccbf..d580e36e45e 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,416 +2,144 @@ from __future__ import annotations -import asyncio -from collections.abc import Iterable -from http import HTTPStatus -import importlib +from dataclasses import dataclass import logging +from typing import TYPE_CHECKING -from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError -from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings +from aiohttp import ClientError +from pysmartthings import ( + Attribute, + Capability, + Device, + Scene, + SmartThings, + SmartThingsAuthenticationFailedError, + Status, +) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_loaded_integration -from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) -from .config_flow import SmartThingsFlowHandler # noqa: F401 -from .const import ( - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, - TOKEN_REFRESH_INTERVAL, -) -from .smartapp import ( - format_unique_id, - setup_smartapp, - setup_smartapp_endpoint, - smartapp_sync_subscriptions, - unload_smartapp_endpoint, - validate_installed_app, - validate_webhook_requirements, -) +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +@dataclass +class SmartThingsData: + """Define an object to hold SmartThings data.""" + + devices: dict[str, FullDevice] + scenes: dict[str, Scene] + client: SmartThings -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize the SmartThings platform.""" - await setup_smartapp_endpoint(hass, False) - return True +@dataclass +class FullDevice: + """Define an object to hold device data.""" + + device: Device + status: dict[str, dict[Capability, dict[Attribute, Status]]] -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle migration of a previous version config entry. +type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] - A config entry created under a previous version must go through the - integration setup again so we can properly retrieve the needed data - elements. Force this by removing the entry and triggering a new flow. - """ - # Remove the entry which will invoke the callback to delete the app. - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - - # Return False because it could not be migrated. - return False +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SCENE, + Platform.SENSOR, + Platform.SWITCH, +] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) -> bool: """Initialize config entry which represents an installed SmartApp.""" - # For backwards compat - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, - unique_id=format_unique_id( - entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID] - ), - ) - - if not validate_webhook_requirements(hass): - _LOGGER.warning( - "The 'base_url' of the 'http' integration must be configured and start with" - " 'https://'" - ) - return False - - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) - - # Ensure platform modules are loaded since the DeviceBroker will - # import them below and we want them to be cached ahead of time - # so the integration does not do blocking I/O in the event loop - # to import the modules. - await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) + # The oauth smartthings entry will have a token, older ones are version 3 + # after migration but still require reauthentication + if CONF_TOKEN not in entry.data: + raise ConfigEntryAuthFailed("Config entry missing token") + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) try: - # See if the app is already setup. This occurs when there are - # installs in multiple SmartThings locations (valid use-case) - manager = hass.data[DOMAIN][DATA_MANAGER] - smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) - if not smart_app: - # Validate and setup the app. - app = await api.app(entry.data[CONF_APP_ID]) - smart_app = setup_smartapp(hass, app) + await session.async_ensure_token_valid() + except ClientError as err: + raise ConfigEntryNotReady from err - # Validate and retrieve the installed app. - installed_app = await validate_installed_app( - api, entry.data[CONF_INSTALLED_APP_ID] - ) + client = SmartThings(session=async_get_clientsession(hass)) - # Get scenes - scenes = await async_get_entry_scenes(entry, api) + async def _refresh_token() -> str: + await session.async_ensure_token_valid() + token = session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token - # Get SmartApp token to sync subscriptions - token = await api.generate_tokens( - entry.data[CONF_CLIENT_ID], - entry.data[CONF_CLIENT_SECRET], - entry.data[CONF_REFRESH_TOKEN], - ) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token} - ) + client.refresh_token_function = _refresh_token - # Get devices and their current status - devices = await api.devices(location_ids=[installed_app.location_id]) + device_status: dict[str, FullDevice] = {} + try: + devices = await client.get_devices() + for device in devices: + status = await client.get_device_status(device.device_id) + device_status[device.device_id] = FullDevice(device=device, status=status) + except SmartThingsAuthenticationFailedError as err: + raise ConfigEntryAuthFailed from err - async def retrieve_device_status(device): - try: - await device.status.refresh() - except ClientResponseError: - _LOGGER.debug( - ( - "Unable to update status for device: %s (%s), the device will" - " be excluded" - ), - device.label, - device.device_id, - exc_info=True, - ) - devices.remove(device) + scenes = { + scene.scene_id: scene + for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) + } - await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy())) + entry.runtime_data = SmartThingsData( + devices={ + device_id: device + for device_id, device in device_status.items() + if MAIN in device.status + }, + client=client, + scenes=scenes, + ) - # Sync device subscriptions - await smartapp_sync_subscriptions( - hass, - token.access_token, - installed_app.location_id, - installed_app.installed_app_id, - devices, - ) - - # Setup device broker - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): - # DeviceBroker has a side effect of importing platform - # modules when its created. In the future this should be - # refactored to not do this. - broker = await hass.async_add_import_executor_job( - DeviceBroker, hass, entry, token, smart_app, devices, scenes - ) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker - - except APIInvalidGrant as ex: - raise ConfigEntryAuthFailed from ex - except ClientResponseError as ex: - if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - raise ConfigEntryError( - "The access token is no longer valid. Please remove the integration and set up again." - ) from ex - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex - except (ClientConnectionError, RuntimeWarning) as ex: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] + ), + "smartthings_webhook", + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True -async def async_get_entry_scenes(entry: ConfigEntry, api): - """Get the scenes within an integration.""" - try: - return await api.scenes(location_id=entry.data[CONF_LOCATION_ID]) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.exception( - ( - "Unable to load scenes for configuration entry '%s' because the" - " access token does not have the required access" - ), - entry.title, - ) - else: - raise - return [] - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SmartThingsConfigEntry +) -> bool: """Unload a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None) - if broker: - broker.disconnect() - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Perform clean-up when entry is being removed.""" - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle config entry migration.""" - # Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error. - installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - try: - await api.delete_installed_app(installed_app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug( - "Installed app %s has already been removed", - installed_app_id, - exc_info=True, - ) - else: - raise - _LOGGER.debug("Removed installed app %s", installed_app_id) - - # Remove the app if not referenced by other entries, which if already - # removed raises a HTTPStatus.FORBIDDEN error. - all_entries = hass.config_entries.async_entries(DOMAIN) - app_id = entry.data[CONF_APP_ID] - app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) - if app_count > 1: - _LOGGER.debug( - ( - "App %s was not removed because it is in use by other configuration" - " entries" - ), - app_id, - ) - return - # Remove the app - try: - await api.delete_app(app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug("App %s has already been removed", app_id, exc_info=True) - else: - raise - _LOGGER.debug("Removed app %s", app_id) - - if len(all_entries) == 1: - await unload_smartapp_endpoint(hass) - - -class DeviceBroker: - """Manages an individual SmartThings config entry.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - token, - smart_app, - devices: Iterable, - scenes: Iterable, - ) -> None: - """Create a new instance of the DeviceBroker.""" - self._hass = hass - self._entry = entry - self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - self._smart_app = smart_app - self._token = token - self._event_disconnect = None - self._regenerate_token_remove = None - self._assignments = self._assign_capabilities(devices) - self.devices = {device.device_id: device for device in devices} - self.scenes = {scene.scene_id: scene for scene in scenes} - - def _assign_capabilities(self, devices: Iterable): - """Assign platforms to capabilities.""" - assignments = {} - for device in devices: - capabilities = device.capabilities.copy() - slots = {} - for platform in PLATFORMS: - platform_module = importlib.import_module( - f".{platform}", self.__module__ - ) - if not hasattr(platform_module, "get_capabilities"): - continue - assigned = platform_module.get_capabilities(capabilities) - if not assigned: - continue - # Draw-down capabilities and set slot assignment - for capability in assigned: - if capability not in capabilities: - continue - capabilities.remove(capability) - slots[capability] = platform - assignments[device.device_id] = slots - return assignments - - def connect(self): - """Connect handlers/listeners for device/lifecycle events.""" - - # Setup interval to regenerate the refresh token on a periodic basis. - # Tokens expire in 30 days and once expired, cannot be recovered. - async def regenerate_refresh_token(now): - """Generate a new refresh token and update the config entry.""" - await self._token.refresh( - self._entry.data[CONF_CLIENT_ID], - self._entry.data[CONF_CLIENT_SECRET], - ) - self._hass.config_entries.async_update_entry( - self._entry, - data={ - **self._entry.data, - CONF_REFRESH_TOKEN: self._token.refresh_token, - }, - ) - _LOGGER.debug( - "Regenerated refresh token for installed app: %s", - self._installed_app_id, - ) - - self._regenerate_token_remove = async_track_time_interval( - self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL + if entry.version < 3: + # We keep the old data around, so we can use that to clean up the webhook in the future + hass.config_entries.async_update_entry( + entry, version=3, data={OLD_DATA: dict(entry.data)} ) - # Connect handler to incoming device events - self._event_disconnect = self._smart_app.connect_event(self._event_handler) - - def disconnect(self): - """Disconnects handlers/listeners for device/lifecycle events.""" - if self._regenerate_token_remove: - self._regenerate_token_remove() - if self._event_disconnect: - self._event_disconnect() - - def get_assigned(self, device_id: str, platform: str): - """Get the capabilities assigned to the platform.""" - slots = self._assignments.get(device_id, {}) - return [key for key, value in slots.items() if value == platform] - - def any_assigned(self, device_id: str, platform: str): - """Return True if the platform has any assigned capabilities.""" - slots = self._assignments.get(device_id, {}) - return any(value for value in slots.values() if value == platform) - - async def _event_handler(self, req, resp, app): - """Broker for incoming events.""" - # Do not process events received from a different installed app - # under the same parent SmartApp (valid use-scenario) - if req.installed_app_id != self._installed_app_id: - return - - updated_devices = set() - for evt in req.events: - if evt.event_type != EVENT_TYPE_DEVICE: - continue - if not (device := self.devices.get(evt.device_id)): - continue - device.status.apply_attribute_update( - evt.component_id, - evt.capability, - evt.attribute, - evt.value, - data=evt.data, - ) - - # Fire events for buttons - if ( - evt.capability == Capability.button - and evt.attribute == Attribute.button - ): - data = { - "component_id": evt.component_id, - "device_id": evt.device_id, - "location_id": evt.location_id, - "value": evt.value, - "name": device.label, - "data": evt.data, - } - self._hass.bus.async_fire(EVENT_BUTTON, data) - _LOGGER.debug("Fired button event: %s", data) - else: - data = { - "location_id": evt.location_id, - "device_id": evt.device_id, - "component_id": evt.component_id, - "capability": evt.capability, - "attribute": evt.attribute, - "value": evt.value, - "data": evt.data, - } - _LOGGER.debug("Push update received: %s", data) - - updated_devices.add(device.device_id) - - async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices) + return True diff --git a/homeassistant/components/smartthings/application_credentials.py b/homeassistant/components/smartthings/application_credentials.py new file mode 100644 index 00000000000..1e637c6bd12 --- /dev/null +++ b/homeassistant/components/smartthings/application_credentials.py @@ -0,0 +1,64 @@ +"""Application credentials platform for SmartThings.""" + +from json import JSONDecodeError +import logging +from typing import cast + +from aiohttp import BasicAuth, ClientError + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return auth implementation.""" + return SmartThingsOAuth2Implementation( + hass, + DOMAIN, + credential, + authorization_server=AuthorizationServer( + authorize_url="https://api.smartthings.com/oauth/authorize", + token_url="https://auth-global.api.smartthings.com/oauth/token", + ), + ) + + +class SmartThingsOAuth2Implementation(AuthImplementation): + """Oauth2 implementation that only uses the external url.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + + resp = await session.post( + self.token_url, + data=data, + auth=BasicAuth(self.client_id, self.client_secret), + ) + if resp.status >= 400: + try: + error_response = await resp.json() + except (ClientError, JSONDecodeError): + error_response = {} + error_code = error_response.get("error", "unknown") + error_description = error_response.get("error_description", "unknown error") + _LOGGER.error( + "Token request for %s failed (%s): %s", + self.domain, + error_code, + error_description, + ) + resp.raise_for_status() + return cast(dict, await resp.json()) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 6b511c86677..6afa4edcf17 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -2,84 +2,144 @@ from __future__ import annotations -from collections.abc import Sequence +from dataclasses import dataclass -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity -CAPABILITY_TO_ATTRIB = { - Capability.acceleration_sensor: Attribute.acceleration, - Capability.contact_sensor: Attribute.contact, - Capability.filter_status: Attribute.filter_status, - Capability.motion_sensor: Attribute.motion, - Capability.presence_sensor: Attribute.presence, - Capability.sound_sensor: Attribute.sound, - Capability.tamper_alert: Attribute.tamper, - Capability.valve: Attribute.valve, - Capability.water_sensor: Attribute.water, -} -ATTRIB_TO_CLASS = { - Attribute.acceleration: BinarySensorDeviceClass.MOVING, - Attribute.contact: BinarySensorDeviceClass.OPENING, - Attribute.filter_status: BinarySensorDeviceClass.PROBLEM, - Attribute.motion: BinarySensorDeviceClass.MOTION, - Attribute.presence: BinarySensorDeviceClass.PRESENCE, - Attribute.sound: BinarySensorDeviceClass.SOUND, - Attribute.tamper: BinarySensorDeviceClass.PROBLEM, - Attribute.valve: BinarySensorDeviceClass.OPENING, - Attribute.water: BinarySensorDeviceClass.MOISTURE, -} -ATTRIB_TO_ENTTIY_CATEGORY = { - Attribute.tamper: EntityCategory.DIAGNOSTIC, + +@dataclass(frozen=True, kw_only=True) +class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe a SmartThings binary sensor entity.""" + + is_on_key: str + + +CAPABILITY_TO_SENSORS: dict[ + Capability, dict[Attribute, SmartThingsBinarySensorEntityDescription] +] = { + Capability.ACCELERATION_SENSOR: { + Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription( + key=Attribute.ACCELERATION, + device_class=BinarySensorDeviceClass.MOVING, + is_on_key="active", + ) + }, + Capability.CONTACT_SENSOR: { + Attribute.CONTACT: SmartThingsBinarySensorEntityDescription( + key=Attribute.CONTACT, + device_class=BinarySensorDeviceClass.DOOR, + is_on_key="open", + ) + }, + Capability.FILTER_STATUS: { + Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( + key=Attribute.FILTER_STATUS, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="replace", + ) + }, + Capability.MOTION_SENSOR: { + Attribute.MOTION: SmartThingsBinarySensorEntityDescription( + key=Attribute.MOTION, + device_class=BinarySensorDeviceClass.MOTION, + is_on_key="active", + ) + }, + Capability.PRESENCE_SENSOR: { + Attribute.PRESENCE: SmartThingsBinarySensorEntityDescription( + key=Attribute.PRESENCE, + device_class=BinarySensorDeviceClass.PRESENCE, + is_on_key="present", + ) + }, + Capability.SOUND_SENSOR: { + Attribute.SOUND: SmartThingsBinarySensorEntityDescription( + key=Attribute.SOUND, + device_class=BinarySensorDeviceClass.SOUND, + is_on_key="detected", + ) + }, + Capability.TAMPER_ALERT: { + Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( + key=Attribute.TAMPER, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="detected", + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, + Capability.VALVE: { + Attribute.VALVE: SmartThingsBinarySensorEntityDescription( + key=Attribute.VALVE, + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, + Capability.WATER_SENSOR: { + Attribute.WATER: SmartThingsBinarySensorEntityDescription( + key=Attribute.WATER, + device_class=BinarySensorDeviceClass.MOISTURE, + is_on_key="wet", + ) + }, } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add binary sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - sensors = [] - for device in broker.devices.values(): - for capability in broker.get_assigned(device.device_id, "binary_sensor"): - attrib = CAPABILITY_TO_ATTRIB[capability] - sensors.append(SmartThingsBinarySensor(device, attrib)) - async_add_entities(sensors) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_ATTRIB if capability in capabilities - ] + entry_data = entry.runtime_data + async_add_entities( + SmartThingsBinarySensor( + entry_data.client, device, description, capability, attribute + ) + for device in entry_data.devices.values() + for capability, attribute_map in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, description in attribute_map.items() + ) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): """Define a SmartThings Binary Sensor.""" - def __init__(self, device, attribute): + entity_description: SmartThingsBinarySensorEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsBinarySensorEntityDescription, + capability: Capability, + attribute: Attribute, + ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, {capability}) self._attribute = attribute - self._attr_name = f"{device.label} {attribute}" - self._attr_unique_id = f"{device.device_id}.{attribute}" - self._attr_device_class = ATTRIB_TO_CLASS[attribute] - self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) + self.capability = capability + self.entity_description = entity_description + self._attr_name = f"{device.device.label} {attribute}" + self._attr_unique_id = f"{device.device.device_id}.{attribute}" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._device.status.is_on(self._attribute) + return ( + self.get_attribute_value(self.capability, self._attribute) + == self.entity_description.is_on_key + ) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 238f8015620..2e05fb2fc4f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -3,17 +3,15 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable, Sequence import logging from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN as CLIMATE_DOMAIN, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -23,12 +21,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" @@ -97,124 +95,106 @@ UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) +AC_CAPABILITIES = [ + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.SWITCH, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +] + +THERMOSTAT_CAPABILITIES = [ + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, +] + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add climate entities for a config entry.""" - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, + entry_data = entry.runtime_data + entities: list[ClimateEntity] = [ + SmartThingsAirConditioner(entry_data.client, device) + for device in entry_data.devices.values() + if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] - - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - entities: list[ClimateEntity] = [] - for device in broker.devices.values(): - if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN): - continue - if all(capability in device.capabilities for capability in ac_capabilities): - entities.append(SmartThingsAirConditioner(device)) - else: - entities.append(SmartThingsThermostat(device)) - async_add_entities(entities, True) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.thermostat, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_fan_mode, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - ] - # Can have this legacy/deprecated capability - if Capability.thermostat in capabilities: - return supported - # Or must have all of these thermostat capabilities - thermostat_capabilities = [ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ] - if all(capability in capabilities for capability in thermostat_capabilities): - return supported - # Or must have all of these A/C capabilities - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - ] - if all(capability in capabilities for capability in ac_capabilities): - return supported - return None + entities.extend( + SmartThingsThermostat(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES + ) + ) + async_add_entities(entities) class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) + super().__init__( + client, + device, + { + Capability.THERMOSTAT_FAN_MODE, + Capability.THERMOSTAT_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_OPERATING_STATE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + }, + ) self._attr_supported_features = self._determine_features() - self._hvac_mode = None - self._hvac_modes = None - def _determine_features(self): + def _determine_features(self) -> ClimateEntityFeature: flags = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability( - Capability.thermostat_fan_mode, Capability.thermostat + if self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE ): flags |= ClimateEntityFeature.FAN_MODE return flags async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_thermostat_fan_mode(fan_mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" - mode = STATE_TO_MODE[hvac_mode] - await self._device.set_thermostat_mode(mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + argument=STATE_TO_MODE[hvac_mode], + ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new operation mode and target temperatures.""" + hvac_mode = self.hvac_mode # Operation state if operation_state := kwargs.get(ATTR_HVAC_MODE): - mode = STATE_TO_MODE[operation_state] - await self._device.set_thermostat_mode(mode, set_status=True) - await self.async_update() + await self.async_set_hvac_mode(operation_state) + hvac_mode = operation_state # Heat/cool setpoint heating_setpoint = None cooling_setpoint = None - if self.hvac_mode == HVACMode.HEAT: + if hvac_mode == HVACMode.HEAT: heating_setpoint = kwargs.get(ATTR_TEMPERATURE) - elif self.hvac_mode == HVACMode.COOL: + elif hvac_mode == HVACMode.COOL: cooling_setpoint = kwargs.get(ATTR_TEMPERATURE) else: heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW) @@ -222,135 +202,145 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): tasks = [] if heating_setpoint is not None: tasks.append( - self._device.set_heating_setpoint( - round(heating_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + argument=round(heating_setpoint, 3), ) ) if cooling_setpoint is not None: tasks.append( - self._device.set_cooling_setpoint( - round(cooling_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=round(cooling_setpoint, 3), ) ) await asyncio.gather(*tasks) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: - """Update the attributes of the climate device.""" - thermostat_mode = self._device.status.thermostat_mode - self._hvac_mode = MODE_TO_STATE.get(thermostat_mode) - if self._hvac_mode is None: - _LOGGER.debug( - "Device %s (%s) returned an invalid hvac mode: %s", - self._device.label, - self._device.device_id, - thermostat_mode, - ) - - modes = set() - supported_modes = self._device.status.supported_thermostat_modes - if isinstance(supported_modes, Iterable): - for mode in supported_modes: - if (state := MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - ( - "Device %s (%s) returned an invalid supported thermostat" - " mode: %s" - ), - self._device.label, - self._device.device_id, - mode, - ) - else: - _LOGGER.debug( - "Device %s (%s) returned invalid supported thermostat modes: %s", - self._device.label, - self._device.device_id, - supported_modes, - ) - self._hvac_modes = list(modes) - @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" - return self._device.status.humidity + if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT): + return self.get_attribute_value( + Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY + ) + return None @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the fan setting.""" - return self._device.status.thermostat_fan_mode + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE + ) @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_thermostat_fan_modes + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES + ) @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return OPERATING_STATE_TO_ACTION.get( - self._device.status.thermostat_operating_state + self.get_attribute_value( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + ) ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return self._hvac_mode + return MODE_TO_STATE.get( + self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE + ) + ) @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return self._hvac_modes + return [ + state + for mode in self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ] @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) if self.hvac_mode == HVACMode.HEAT: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) return None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit) + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" - _hvac_modes: list[HVACMode] + _attr_preset_mode = None - def __init__(self, device) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) - self._hvac_modes = [] - self._attr_preset_mode = None + super().__init__( + client, + device, + { + Capability.AIR_CONDITIONER_MODE, + Capability.SWITCH, + Capability.FAN_OSCILLATION_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + }, + ) + self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() @@ -362,7 +352,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability(Capability.fan_oscillation_mode): + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): features |= ClimateEntityFeature.SWING_MODE if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: features |= ClimateEntityFeature.PRESET_MODE @@ -370,14 +360,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_fan_mode(fan_mode, set_status=True) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" @@ -386,23 +373,27 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return tasks = [] # Turn on the device if it's off before setting mode. - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind" # The conversion make the mode change working # The conversion is made only for device that wrongly has capability "wind" instead "fan_only" if hvac_mode == HVACMode.FAN_ONLY: - supported_modes = self._device.status.supported_ac_modes - if WIND in supported_modes: + if WIND in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ): mode = WIND - tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True)) + tasks.append( + self.execute_device_command( + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + argument=mode, + ) + ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -410,53 +401,44 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): # operation mode if operation_mode := kwargs.get(ATTR_HVAC_MODE): if operation_mode == HVACMode.OFF: - tasks.append(self._device.switch_off(set_status=True)) + tasks.append(self.async_turn_off()) else: - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if ( + self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + == "off" + ): + tasks.append(self.async_turn_on()) tasks.append(self.async_set_hvac_mode(operation_mode)) # temperature tasks.append( - self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True) + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=kwargs[ATTR_TEMPERATURE], + ) ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_turn_on(self) -> None: """Turn device on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self) -> None: """Turn device off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() - - async def async_update(self) -> None: - """Update the calculated fields of the AC.""" - modes = {HVACMode.OFF} - for mode in self._device.status.supported_ac_modes: - if (state := AC_MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - "Device %s (%s) returned an invalid supported AC mode: %s", - self._device.label, - self._device.device_id, - mode, - ) - self._hvac_modes = list(modes) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -465,100 +447,114 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ - attributes = [ - "drlc_status_duration", - "drlc_status_level", - "drlc_status_start", - "drlc_status_override", - ] - state_attributes = {} - for attribute in attributes: - value = getattr(self._device.status, attribute) - if value is not None: - state_attributes[attribute] = value - return state_attributes + drlc_status = self.get_attribute_value( + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, + ) + return { + "drlc_status_duration": drlc_status["duration"], + "drlc_status_level": drlc_status["drlcLevel"], + "drlc_status_start": drlc_status["start"], + "drlc_status_override": drlc_status["override"], + } @property def fan_mode(self) -> str: """Return the fan setting.""" - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - if not self._device.status.switch: + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": return HVACMode.OFF - return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode) - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return self._hvac_modes + return AC_MODE_TO_STATE.get( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + ) @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - supported_swings = None - supported_modes = self._device.status.attributes[ - Attribute.supported_fan_oscillation_modes - ][0] - if supported_modes is not None: - supported_swings = [ - FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes - ] - return supported_swings + if ( + supported_modes := self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ) + ) is None: + return None + return [FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes] async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing mode.""" - fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode] - await self._device.set_fan_oscillation_mode(fan_oscillation_mode) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + argument=SWING_TO_FAN_OSCILLATION[swing_mode], + ) @property def swing_mode(self) -> str: """Return the swing setting.""" return FAN_OSCILLATION_TO_SWING.get( - self._device.status.fan_oscillation_mode, SWING_OFF + self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE + ), + SWING_OFF, ) def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes: list | None = self._device.status.attributes[ - "supportedAcOptionalMode" - ].value - if supported_modes and WINDFREE in supported_modes: - return [WINDFREE] + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + supported_modes = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.SUPPORTED_AC_OPTIONAL_MODE, + ) + if supported_modes and WINDFREE in supported_modes: + return [WINDFREE] return None async def async_set_preset_mode(self, preset_mode: str) -> None: """Set special modes (currently only windFree is supported).""" - result = await self._device.command( - "main", - "custom.airConditionerOptionalMode", - "setAcOptionalMode", - [preset_mode], + await self.execute_device_command( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + argument=preset_mode, ) - if result: - self._device.status.update_attribute_value("acOptionalMode", preset_mode) - self._attr_preset_mode = preset_mode - - self.async_write_ha_state() + def _determine_hvac_modes(self) -> list[HVACMode]: + """Determine the supported HVAC modes.""" + modes = [HVACMode.OFF] + modes.extend( + state + for mode in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ) + return modes diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 7b49854740a..bcd2ddc192b 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,298 +1,83 @@ """Config flow to configure SmartThings.""" from collections.abc import Mapping -from http import HTTPStatus import logging from typing import Any -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError, AppOAuth, SmartThings -from pysmartthings.installedapp import format_install_url -import voluptuous as vol +from pysmartthings import SmartThings -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +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 .const import ( - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DOMAIN, - VAL_UID_MATCHER, -) -from .smartapp import ( - create_app, - find_app, - format_unique_id, - get_webhook_url, - setup_smartapp, - setup_smartapp_endpoint, - update_app, - validate_webhook_requirements, -) +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES _LOGGER = logging.getLogger(__name__) -class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): +class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle configuration of SmartThings integrations.""" - VERSION = 2 + VERSION = 3 + DOMAIN = DOMAIN - api: SmartThings - app_id: str - location_id: str + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) - def __init__(self) -> None: - """Create a new instance of the flow handler.""" - self.access_token: str | None = None - self.oauth_client_secret = None - self.oauth_client_id = None - self.installed_app_id = None - self.refresh_token = None - self.endpoints_initialized = False + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(SCOPES)} - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Occurs when a previously entry setup fails and is re-initiated.""" - return await self.async_step_user(import_data) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for SmartThings.""" + client = SmartThings(session=async_get_clientsession(self.hass)) + client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + locations = await client.get_locations() + location = locations[0] + # We pick to use the location id as unique id rather than the installed app id + # as the installed app id could change with the right settings in the SmartApp + # or the app used to sign in changed for any reason. + await self.async_set_unique_id(location.location_id) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Validate and confirm webhook setup.""" - if not self.endpoints_initialized: - self.endpoints_initialized = True - await setup_smartapp_endpoint( - self.hass, len(self._async_current_entries()) == 0 + return self.async_create_entry( + title=location.name, + data={**data, CONF_LOCATION_ID: location.location_id}, ) - webhook_url = get_webhook_url(self.hass) - # Abort if the webhook is invalid - if not validate_webhook_requirements(self.hass): - return self.async_abort( - reason="invalid_webhook_url", - description_placeholders={ - "webhook_url": webhook_url, - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), + if (entry := self._get_reauth_entry()) and CONF_TOKEN not in entry.data: + if entry.data[OLD_DATA][CONF_LOCATION_ID] != location.location_id: + return self.async_abort(reason="reauth_location_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + **data, + CONF_LOCATION_ID: location.location_id, }, + unique_id=location.location_id, ) - - # Show the confirmation - if user_input is None: - return self.async_show_form( - step_id="user", - description_placeholders={"webhook_url": webhook_url}, - ) - - # Show the next screen - return await self.async_step_pat() - - async def async_step_pat( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Get the Personal Access Token and validate it.""" - errors: dict[str, str] = {} - if user_input is None or CONF_ACCESS_TOKEN not in user_input: - return self._show_step_pat(errors) - - self.access_token = user_input[CONF_ACCESS_TOKEN] - - # Ensure token is a UUID - if not VAL_UID_MATCHER.match(self.access_token): - errors[CONF_ACCESS_TOKEN] = "token_invalid_format" - return self._show_step_pat(errors) - - # Setup end-point - self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) - try: - app = await find_app(self.hass, self.api) - if app: - await app.refresh() # load all attributes - await update_app(self.hass, app) - # Find an existing entry to copy the oauth client - existing = next( - ( - entry - for entry in self._async_current_entries() - if entry.data[CONF_APP_ID] == app.app_id - ), - None, - ) - if existing: - self.oauth_client_id = existing.data[CONF_CLIENT_ID] - self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET] - else: - # Get oauth client id/secret by regenerating it - app_oauth = AppOAuth(app.app_id) - app_oauth.client_name = APP_OAUTH_CLIENT_NAME - app_oauth.scope.extend(APP_OAUTH_SCOPES) - client = await self.api.generate_app_oauth(app_oauth) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - else: - app, client = await create_app(self.hass, self.api) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - setup_smartapp(self.hass, app) - self.app_id = app.app_id - - except APIResponseError as ex: - if ex.is_target_error(): - errors["base"] = "webhook_error" - else: - errors["base"] = "app_setup_error" - _LOGGER.exception( - "API error setting up the SmartApp: %s", ex.raw_error_response - ) - return self._show_step_pat(errors) - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "token_unauthorized" - _LOGGER.debug( - "Unauthorized error received setting up SmartApp", exc_info=True - ) - elif ex.status == HTTPStatus.FORBIDDEN: - errors[CONF_ACCESS_TOKEN] = "token_forbidden" - _LOGGER.debug( - "Forbidden error received setting up SmartApp", exc_info=True - ) - else: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - except Exception: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - - return await self.async_step_select_location() - - async def async_step_select_location( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Ask user to select the location to setup.""" - if user_input is None or CONF_LOCATION_ID not in user_input: - # Get available locations - existing_locations = [ - entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries() - ] - locations = await self.api.locations() - locations_options = { - location.location_id: location.name - for location in locations - if location.location_id not in existing_locations - } - if not locations_options: - return self.async_abort(reason="no_available_locations") - - return self.async_show_form( - step_id="select_location", - data_schema=vol.Schema( - {vol.Required(CONF_LOCATION_ID): vol.In(locations_options)} - ), - ) - - self.location_id = user_input[CONF_LOCATION_ID] - await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) - return await self.async_step_authorize() - - async def async_step_authorize( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Wait for the user to authorize the app installation.""" - user_input = {} if user_input is None else user_input - self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID) - self.refresh_token = user_input.get(CONF_REFRESH_TOKEN) - if self.installed_app_id is None: - # Launch the external setup URL - url = format_install_url(self.app_id, self.location_id) - return self.async_external_step(step_id="authorize", url=url) - - next_step_id = "install" - if self.source == SOURCE_REAUTH: - next_step_id = "update" - return self.async_external_step_done(next_step_id=next_step_id) - - def _show_step_pat(self, errors): - if self.access_token is None: - # Get the token from an existing entry to make it easier to setup multiple locations. - self.access_token = next( - ( - entry.data.get(CONF_ACCESS_TOKEN) - for entry in self._async_current_entries() - ), - None, - ) - - return self.async_show_form( - step_id="pat", - data_schema=vol.Schema( - {vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str} - ), - errors=errors, - description_placeholders={ - "token_url": "https://account.smartthings.com/tokens", - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), - }, + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=data ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" + """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" + """Confirm reauth dialog.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") - self.app_id = self._get_reauth_entry().data[CONF_APP_ID] - self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] - self._set_confirm_only() - return await self.async_step_authorize() - - async def async_step_update( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" - return await self.async_step_update_confirm() - - async def async_step_update_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" - if user_input is None: - self._set_confirm_only() - return self.async_show_form(step_id="update_confirm") - entry = self._get_reauth_entry() - return self.async_update_reload_and_abort( - entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} - ) - - async def async_step_install( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Create a config entry at completion of a flow and authorization of the app.""" - data = { - CONF_ACCESS_TOKEN: self.access_token, - CONF_REFRESH_TOKEN: self.refresh_token, - CONF_CLIENT_ID: self.oauth_client_id, - CONF_CLIENT_SECRET: self.oauth_client_secret, - CONF_LOCATION_ID: self.location_id, - CONF_APP_ID: self.app_id, - CONF_INSTALLED_APP_ID: self.installed_app_id, - } - - location = await self.api.location(data[CONF_LOCATION_ID]) - - return self.async_create_entry(title=location.name, data=data) + return self.async_show_form( + step_id="reauth_confirm", + ) + return await self.async_step_user() diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index e50837697e7..c39d225dd09 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,15 +1,23 @@ """Constants used by the SmartThings component and platforms.""" -from datetime import timedelta -import re - -from homeassistant.const import Platform - DOMAIN = "smartthings" -APP_OAUTH_CLIENT_NAME = "Home Assistant" -APP_OAUTH_SCOPES = ["r:devices:*"] -APP_NAME_PREFIX = "homeassistant." +SCOPES = [ + "r:devices:*", + "w:devices:*", + "x:devices:*", + "r:hubs:*", + "r:locations:*", + "w:locations:*", + "x:locations:*", + "r:scenes:*", + "x:scenes:*", + "r:rules:*", + "w:rules:*", + "r:installedapps", + "w:installedapps", + "sse", +] CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" @@ -18,41 +26,5 @@ CONF_INSTANCE_ID = "instance_id" CONF_LOCATION_ID = "location_id" CONF_REFRESH_TOKEN = "refresh_token" -DATA_MANAGER = "manager" -DATA_BROKERS = "brokers" -EVENT_BUTTON = "smartthings.button" - -SIGNAL_SMARTTHINGS_UPDATE = "smartthings_update" -SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_" - -SETTINGS_INSTANCE_ID = "hassInstanceId" - -SUBSCRIPTION_WARNING_LIMIT = 40 - -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 - -# Ordered 'specific to least-specific platform' in order for capabilities -# to be drawn-down and represented by the most appropriate platform. -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CLIMATE, - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SCENE, - Platform.SENSOR, - Platform.SWITCH, -] - -IGNORED_CAPABILITIES = [ - "execute", - "healthCheck", - "ocf", -] - -TOKEN_REFRESH_INTERVAL = timedelta(days=14) - -VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" -VAL_UID_MATCHER = re.compile(VAL_UID) +MAIN = "main" +OLD_DATA = "old_data" diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index daf9b0f38f8..97a7456d132 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -2,25 +2,23 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as COVER_DOMAIN, CoverDeviceClass, CoverEntity, CoverEntityFeature, CoverState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity VALUE_TO_STATE = { @@ -32,114 +30,99 @@ VALUE_TO_STATE = { "unknown": None, } +CAPABILITIES = (Capability.WINDOW_SHADE, Capability.DOOR_CONTROL) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add covers for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsCover(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, COVER_DOMAIN) - ], - True, + SmartThingsCover(entry_data.client, device, capability) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - min_required = [ - Capability.door_control, - Capability.garage_door_control, - Capability.window_shade, - ] - # Must have one of the min_required - if any(capability in capabilities for capability in min_required): - # Return all capabilities supported/consumed - return [ - *min_required, - Capability.battery, - Capability.switch_level, - Capability.window_shade_level, - ] - - return None - - class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" - def __init__(self, device): + _state: CoverState | None = None + + def __init__( + self, client: SmartThings, device: FullDevice, capability: Capability + ) -> None: """Initialize the cover class.""" - super().__init__(device) - self._current_cover_position = None - self._state = None + super().__init__( + client, + device, + { + capability, + Capability.BATTERY, + Capability.WINDOW_SHADE_LEVEL, + Capability.SWITCH_LEVEL, + }, + ) + self.capability = capability self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if ( - Capability.switch_level in device.capabilities - or Capability.window_shade_level in device.capabilities - ): + if self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self.level_capability = Capability.WINDOW_SHADE_LEVEL + self.level_command = Command.SET_SHADE_LEVEL + else: + self.level_capability = Capability.SWITCH_LEVEL + self.level_command = Command.SET_LEVEL + if self.supports_capability( + Capability.SWITCH_LEVEL + ) or self.supports_capability(Capability.WINDOW_SHADE_LEVEL): self._attr_supported_features |= CoverEntityFeature.SET_POSITION - if Capability.door_control in device.capabilities: + if self.supports_capability(Capability.DOOR_CONTROL): self._attr_device_class = CoverDeviceClass.DOOR - elif Capability.window_shade in device.capabilities: + elif self.supports_capability(Capability.WINDOW_SHADE): self._attr_device_class = CoverDeviceClass.SHADE - elif Capability.garage_door_control in device.capabilities: - self._attr_device_class = CoverDeviceClass.GARAGE async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - # Same command for all 3 supported capabilities - await self._device.close(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.CLOSE) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - # Same for all capability types - await self._device.open(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.OPEN) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if not self.supported_features & CoverEntityFeature.SET_POSITION: - return - # Do not set_status=True as device will report progress. - if Capability.window_shade_level in self._device.capabilities: - await self._device.set_window_shade_level( - kwargs[ATTR_POSITION], set_status=False - ) - else: - await self._device.set_level(kwargs[ATTR_POSITION], set_status=False) + await self.execute_device_command( + self.level_capability, + self.level_command, + argument=kwargs[ATTR_POSITION], + ) - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update the attrs of the cover.""" - if Capability.door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) - elif Capability.window_shade in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.window_shade) - elif Capability.garage_door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) + attribute = { + Capability.WINDOW_SHADE: Attribute.WINDOW_SHADE, + Capability.DOOR_CONTROL: Attribute.DOOR, + }[self.capability] + self._state = VALUE_TO_STATE.get( + self.get_attribute_value(self.capability, attribute) + ) - if Capability.window_shade_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.shade_level - elif Capability.switch_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.level + if self.supports_capability(Capability.SWITCH_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) self._attr_extra_state_attributes = {} - battery = self._device.status.attributes[Attribute.battery].value - if battery is not None: - self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery + if self.supports_capability(Capability.BATTERY): + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = ( + self.get_attribute_value(Capability.BATTERY, Attribute.BATTERY) + ) @property def is_opening(self) -> bool: diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index cc63213d122..f5f1f268801 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,13 +2,15 @@ from __future__ import annotations -from pysmartthings.device import DeviceEntity +from typing import Any, cast + +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from . import FullDevice +from .const import DOMAIN, MAIN class SmartThingsEntity(Entity): @@ -16,35 +18,86 @@ class SmartThingsEntity(Entity): _attr_should_poll = False - def __init__(self, device: DeviceEntity) -> None: + def __init__( + self, client: SmartThings, device: FullDevice, capabilities: set[Capability] + ) -> None: """Initialize the instance.""" - self._device = device - self._dispatcher_remove = None - self._attr_name = device.label - self._attr_unique_id = device.device_id + self.client = client + self.capabilities = capabilities + self._internal_state = { + capability: device.status[MAIN][capability] + for capability in capabilities + if capability in device.status[MAIN] + } + self.device = device + self._attr_name = device.device.label + self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, device.device_id)}, - manufacturer=device.status.ocf_manufacturer_name, - model=device.status.ocf_model_number, - name=device.label, - hw_version=device.status.ocf_hardware_version, - sw_version=device.status.ocf_firmware_version, + identifiers={(DOMAIN, device.device.device_id)}, + name=device.device.label, ) + if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: + self._attr_device_info.update( + { + "manufacturer": cast( + str | None, ocf[Attribute.MANUFACTURER_NAME].value + ), + "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), + "hw_version": cast( + str | None, ocf[Attribute.HARDWARE_VERSION].value + ), + "sw_version": cast( + str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value + ), + } + ) - async def async_added_to_hass(self): - """Device added to hass.""" + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + for capability in self._internal_state: + self.async_on_remove( + self.client.add_device_event_listener( + self.device.device.device_id, + MAIN, + capability, + self._update_handler, + ) + ) + self._update_attr() - async def async_update_state(devices): - """Update device state.""" - if self._device.device_id in devices: - await self.async_update_ha_state(True) + def _update_handler(self, event: DeviceEvent) -> None: + self._internal_state[event.capability][event.attribute].value = event.value + self._internal_state[event.capability][event.attribute].data = event.data + self._handle_update() - self._dispatcher_remove = async_dispatcher_connect( - self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state + def supports_capability(self, capability: Capability) -> bool: + """Test if device supports a capability.""" + return capability in self.device.status[MAIN] + + def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: + """Get the value of a device attribute.""" + return self._internal_state[capability][attribute].value + + def _update_attr(self) -> None: + """Update the attributes.""" + + def _handle_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + self.async_write_ha_state() + + async def execute_device_command( + self, + capability: Capability, + command: Command, + argument: int | str | list[Any] | dict[str, Any] | None = None, + ) -> None: + """Execute a command on the device.""" + kwargs = {} + if argument is not None: + kwargs["argument"] = argument + await self.client.execute_device_command( + self.device.device.device_id, capability, command, MAIN, **kwargs ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect the device when removed.""" - if self._dispatcher_remove: - self._dispatcher_remove() diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 1f26a805dcb..23afb0baeb2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -2,14 +2,12 @@ from __future__ import annotations -from collections.abc import Sequence import math from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -18,7 +16,8 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity SPEED_RANGE = (1, 3) # off is not included @@ -26,86 +25,73 @@ SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add fans for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "fan") + SmartThingsFan(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any( + capability in device.status[MAIN] + for capability in ( + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + ) + ) + and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - - # MUST support switch as we need a way to turn it on and off - if Capability.switch not in capabilities: - return None - - # These are all optional but at least one must be supported - optional = [ - Capability.air_conditioner_fan_mode, - Capability.fan_speed, - ] - - # At least one of the optional capabilities must be supported - # to classify this entity as a fan. - # If they are not then return None and don't setup the platform. - if not any(capability in capabilities for capability in optional): - return None - - supported = [Capability.switch] - - supported.extend( - capability for capability in optional if capability in capabilities - ) - - return supported - - class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) + super().__init__( + client, + device, + { + Capability.SWITCH, + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + }, + ) self._attr_supported_features = self._determine_features() def _determine_features(self): flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - if self._device.get_capability(Capability.fan_speed): + if self.supports_capability(Capability.FAN_SPEED): flags |= FanEntityFeature.SET_SPEED - if self._device.get_capability(Capability.air_conditioner_fan_mode): + if self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): flags |= FanEntityFeature.PRESET_MODE return flags async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - await self._async_set_percentage(percentage) - - async def _async_set_percentage(self, percentage: int | None) -> None: - if percentage is None: - await self._device.switch_on(set_status=True) - elif percentage == 0: - await self._device.switch_off(set_status=True) + if percentage == 0: + await self.execute_device_command(Capability.SWITCH, Command.OFF) else: value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - await self._device.set_fan_speed(value, set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + argument=value, + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - await self._device.set_fan_mode(preset_mode, set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=preset_mode, + ) async def async_turn_on( self, @@ -114,32 +100,30 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - if FanEntityFeature.SET_SPEED in self._attr_supported_features: - # If speed is set in features then turn the fan on with the speed. - await self._async_set_percentage(percentage) + if ( + FanEntityFeature.SET_SPEED in self._attr_supported_features + and percentage is not None + ): + await self.async_set_percentage(percentage) else: - # If speed is not valid then turn on the fan with the - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.ON) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.OFF) @property def is_on(self) -> bool: """Return true if fan is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) @property def percentage(self) -> int | None: """Return the current speed percentage.""" - return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) + return ranged_value_to_percentage( + SPEED_RANGE, + self.get_attribute_value(Capability.FAN_SPEED, Attribute.FAN_SPEED), + ) @property def preset_mode(self) -> str | None: @@ -147,7 +131,9 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def preset_modes(self) -> list[str] | None: @@ -155,4 +141,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 2ee369176cb..582f9dd5435 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -18,54 +17,38 @@ from homeassistant.components.light import ( LightEntityFeature, brightness_supported, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add lights for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsLight(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "light") - ], - True, + SmartThingsLight(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any(capability in device.status[MAIN] for capability in CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ] - # Must be able to be turned on/off. - if Capability.switch not in capabilities: - return None - # Must have one of these - light_capabilities = [ - Capability.color_control, - Capability.color_temperature, - Capability.switch_level, - ] - if any(capability in capabilities for capability in light_capabilities): - return supported - return None - - -def convert_scale(value, value_scale, target_scale, round_digits=4): +def convert_scale( + value: float, value_scale: int, target_scale: int, round_digits: int = 4 +) -> float: """Convert a value to a different scale.""" return round(value * target_scale / value_scale, round_digits) @@ -76,46 +59,41 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): _attr_supported_color_modes: set[ColorMode] # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # lowest kelvin found supported across 20+ handlers. _attr_min_color_temp_kelvin = 2000 # 500 mireds # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize a SmartThingsLight.""" - super().__init__(device) - self._attr_supported_color_modes = self._determine_color_modes() - self._attr_supported_features = self._determine_features() - - def _determine_color_modes(self): - """Get features supported by the device.""" + super().__init__( + client, + device, + { + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.SWITCH_LEVEL, + Capability.SWITCH, + }, + ) color_modes = set() - # Color Temperature - if Capability.color_temperature in self._device.capabilities: + if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) - # Color - if Capability.color_control in self._device.capabilities: + if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) - # Brightness - if not color_modes and Capability.switch_level in self._device.capabilities: + if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) - - return color_modes - - def _determine_features(self) -> LightEntityFeature: - """Get features supported by the device.""" + self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) - # Transition - if Capability.switch_level in self._device.capabilities: + if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION - - return features + self._attr_supported_features = features async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" @@ -136,11 +114,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0) ) else: - await self._device.switch_on(set_status=True) - - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" @@ -148,27 +125,39 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): if ATTR_TRANSITION in kwargs: await self.async_set_level(0, int(kwargs[ATTR_TRANSITION])) else: - await self._device.switch_off(set_status=True) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): self._attr_brightness = int( - convert_scale(self._device.status.level, 100, 255, 0) + convert_scale( + self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), + 100, + 255, + 0, + ) ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._attr_color_temp_kelvin = self._device.status.color_temperature + self._attr_color_temp_kelvin = self.get_attribute_value( + Capability.COLOR_TEMPERATURE, Attribute.COLOR_TEMPERATURE + ) # Color if ColorMode.HS in self._attr_supported_color_modes: self._attr_hs_color = ( - convert_scale(self._device.status.hue, 100, 360), - self._device.status.saturation, + convert_scale( + self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), ) async def async_set_color(self, hs_color): @@ -176,14 +165,22 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): hue = convert_scale(float(hs_color[0]), 360, 100) hue = max(min(hue, 100.0), 0.0) saturation = max(min(float(hs_color[1]), 100.0), 0.0) - await self._device.set_color(hue, saturation, set_status=True) + await self.execute_device_command( + Capability.COLOR_CONTROL, + Command.SET_COLOR, + argument={"hue": hue, "saturation": saturation}, + ) async def async_set_color_temp(self, value: int): """Set the color temperature of the device.""" kelvin = max(min(value, 30000), 1) - await self._device.set_color_temperature(kelvin, set_status=True) + await self.execute_device_command( + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + argument=kelvin, + ) - async def async_set_level(self, brightness: int, transition: int): + async def async_set_level(self, brightness: int, transition: int) -> None: """Set the brightness of the light over transition.""" level = int(convert_scale(brightness, 255, 100, 0)) # Due to rounding, set level to 1 (one) so we don't inadvertently @@ -191,7 +188,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): level = 1 if level == 0 and brightness > 0 else level level = max(min(level, 100), 0) duration = int(transition) - await self._device.set_level(level, duration, set_status=True) + await self.execute_device_command( + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + argument=[level, duration], + ) @property def color_mode(self) -> ColorMode: @@ -208,4 +209,4 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 468b7c2083a..56274dfe161 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -2,17 +2,16 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ST_STATE_LOCKED = "locked" @@ -28,48 +27,47 @@ ST_LOCK_ATTR_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add locks for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "lock") + SmartThingsLock(entry_data.client, device, {Capability.LOCK}) + for device in entry_data.devices.values() + if Capability.LOCK in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - if Capability.lock in capabilities: - return [Capability.lock] - return None - - class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - await self._device.lock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.LOCK, + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - await self._device.unlock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.UNLOCK, + ) @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._device.status.lock == ST_STATE_LOCKED + return ( + self.get_attribute_value(Capability.LOCK, Attribute.LOCK) == ST_STATE_LOCKED + ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" state_attrs = {} - status = self._device.status.attributes[Attribute.lock] + status = self._internal_state[Capability.LOCK][Attribute.LOCK] if status.value: state_attrs["lock_state"] = status.value if isinstance(status.data, dict): diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index be313248eaf..b34ab90ca8c 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -1,10 +1,9 @@ { "domain": "smartthings", "name": "SmartThings", - "after_dependencies": ["cloud"], - "codeowners": [], + "codeowners": ["@joostlek"], "config_flow": true, - "dependencies": ["webhook"], + "dependencies": ["application_credentials"], "dhcp": [ { "hostname": "st*", @@ -29,6 +28,6 @@ ], "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", - "loggers": ["httpsig", "pysmartapp", "pysmartthings"], - "requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"] + "loggers": ["pysmartthings"], + "requirements": ["pysmartthings==1.2.0"] } diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index aa6655b0134..2b387859f22 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -2,39 +2,42 @@ from typing import Any +from pysmartthings import Scene as STScene, SmartThings + from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - async_add_entities(SmartThingsScene(scene) for scene in broker.scenes.values()) + """Add lights for a config entry.""" + client = entry.runtime_data.client + scenes = entry.runtime_data.scenes + async_add_entities(SmartThingsScene(scene, client) for scene in scenes.values()) class SmartThingsScene(Scene): """Define a SmartThings scene.""" - def __init__(self, scene): + def __init__(self, scene: STScene, client: SmartThings) -> None: """Init the scene class.""" + self.client = client self._scene = scene self._attr_name = scene.name self._attr_unique_id = scene.scene_id async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" - await self._scene.execute() + await self.client.execute_scene(self._scene.scene_id) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Get attributes about the state.""" return { "icon": self._scene.icon, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 3a283bb806b..b16d332a1ae 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -2,13 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from typing import Any -from pysmartthings import Attribute, Capability -from pysmartthings.device import DeviceEntity, DeviceStatus +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,14 +15,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfArea, - UnitOfElectricPotential, UnitOfEnergy, UnitOfMass, UnitOfPower, @@ -34,17 +31,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +THERMOSTAT_CAPABILITIES = { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, +} -def power_attributes(status: DeviceStatus) -> dict[str, Any]: + +def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" state = {} - for attribute in ("power_consumption_start", "power_consumption_end"): - value = getattr(status, attribute) - if value is not None: - state[attribute] = value + for attribute in ("start", "end"): + if (value := status.get(attribute)) is not None: + state[f"power_consumption_{attribute}"] = value return state @@ -53,62 +56,70 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): """Describe a SmartThings sensor entity.""" value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value - extra_state_attributes_fn: Callable[[DeviceStatus], dict[str, Any]] | None = None + extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None unique_id_separator: str = "." + capability_ignore_list: list[set[Capability]] | None = None CAPABILITY_TO_SENSORS: dict[ - str, dict[str, list[SmartThingsSensorEntityDescription]] + Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]] ] = { - Capability.activity_lighting_mode: { - Attribute.lighting_mode: [ + # no fixtures + Capability.ACTIVITY_LIGHTING_MODE: { + Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.lighting_mode, + key=Attribute.LIGHTING_MODE, name="Activity Lighting Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.air_conditioner_mode: { - Attribute.air_conditioner_mode: [ + Capability.AIR_CONDITIONER_MODE: { + Attribute.AIR_CONDITIONER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.air_conditioner_mode, + key=Attribute.AIR_CONDITIONER_MODE, name="Air Conditioner Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[ + { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, + } + ], ) ] }, - Capability.air_quality_sensor: { - Attribute.air_quality: [ + Capability.AIR_QUALITY_SENSOR: { + Attribute.AIR_QUALITY: [ SmartThingsSensorEntityDescription( - key=Attribute.air_quality, + key=Attribute.AIR_QUALITY, name="Air Quality", native_unit_of_measurement="CAQI", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.alarm: { - Attribute.alarm: [ + Capability.ALARM: { + Attribute.ALARM: [ SmartThingsSensorEntityDescription( - key=Attribute.alarm, + key=Attribute.ALARM, name="Alarm", ) ] }, - Capability.audio_volume: { - Attribute.volume: [ + Capability.AUDIO_VOLUME: { + Attribute.VOLUME: [ SmartThingsSensorEntityDescription( - key=Attribute.volume, + key=Attribute.VOLUME, name="Volume", native_unit_of_measurement=PERCENTAGE, ) ] }, - Capability.battery: { - Attribute.battery: [ + Capability.BATTERY: { + Attribute.BATTERY: [ SmartThingsSensorEntityDescription( - key=Attribute.battery, + key=Attribute.BATTERY, name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -116,20 +127,22 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.body_mass_index_measurement: { - Attribute.bmi_measurement: [ + # no fixtures + Capability.BODY_MASS_INDEX_MEASUREMENT: { + Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.bmi_measurement, + key=Attribute.BMI_MEASUREMENT, name="Body Mass Index", native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.body_weight_measurement: { - Attribute.body_weight_measurement: [ + # no fixtures + Capability.BODY_WEIGHT_MEASUREMENT: { + Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.body_weight_measurement, + key=Attribute.BODY_WEIGHT_MEASUREMENT, name="Body Weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, @@ -137,10 +150,11 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.carbon_dioxide_measurement: { - Attribute.carbon_dioxide: [ + # no fixtures + Capability.CARBON_DIOXIDE_MEASUREMENT: { + Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_dioxide, + key=Attribute.CARBON_DIOXIDE, name="Carbon Dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -148,18 +162,20 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.carbon_monoxide_detector: { - Attribute.carbon_monoxide: [ + # no fixtures + Capability.CARBON_MONOXIDE_DETECTOR: { + Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_monoxide, + key=Attribute.CARBON_MONOXIDE, name="Carbon Monoxide Detector", ) ] }, - Capability.carbon_monoxide_measurement: { - Attribute.carbon_monoxide_level: [ + # no fixtures + Capability.CARBON_MONOXIDE_MEASUREMENT: { + Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_monoxide_level, + key=Attribute.CARBON_MONOXIDE_LEVEL, name="Carbon Monoxide Level", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO, @@ -167,79 +183,80 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.dishwasher_operating_state: { - Attribute.machine_state: [ + Capability.DISHWASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Dishwasher Machine State", ) ], - Attribute.dishwasher_job_state: [ + Attribute.DISHWASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.dishwasher_job_state, + key=Attribute.DISHWASHER_JOB_STATE, name="Dishwasher Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Dishwasher Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], }, - Capability.dryer_mode: { - Attribute.dryer_mode: [ + # part of the proposed spec, no fixtures + Capability.DRYER_MODE: { + Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.dryer_mode, + key=Attribute.DRYER_MODE, name="Dryer Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.dryer_operating_state: { - Attribute.machine_state: [ + Capability.DRYER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Dryer Machine State", ) ], - Attribute.dryer_job_state: [ + Attribute.DRYER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.dryer_job_state, + key=Attribute.DRYER_JOB_STATE, name="Dryer Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Dryer Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], }, - Capability.dust_sensor: { - Attribute.fine_dust_level: [ + Capability.DUST_SENSOR: { + Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.fine_dust_level, - name="Fine Dust Level", - state_class=SensorStateClass.MEASUREMENT, - ) - ], - Attribute.dust_level: [ - SmartThingsSensorEntityDescription( - key=Attribute.dust_level, + key=Attribute.DUST_LEVEL, name="Dust Level", state_class=SensorStateClass.MEASUREMENT, ) ], - }, - Capability.energy_meter: { - Attribute.energy: [ + Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.energy, + key=Attribute.FINE_DUST_LEVEL, + name="Fine Dust Level", + state_class=SensorStateClass.MEASUREMENT, + ) + ], + }, + Capability.ENERGY_METER: { + Attribute.ENERGY: [ + SmartThingsSensorEntityDescription( + key=Attribute.ENERGY, name="Energy Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -247,10 +264,11 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.equivalent_carbon_dioxide_measurement: { - Attribute.equivalent_carbon_dioxide_measurement: [ + # no fixtures + Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: { + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.equivalent_carbon_dioxide_measurement, + key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, name="Equivalent Carbon Dioxide Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -258,43 +276,45 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.formaldehyde_measurement: { - Attribute.formaldehyde_level: [ + # no fixtures + Capability.FORMALDEHYDE_MEASUREMENT: { + Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.formaldehyde_level, + key=Attribute.FORMALDEHYDE_LEVEL, name="Formaldehyde Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.gas_meter: { - Attribute.gas_meter: [ + # no fixtures + Capability.GAS_METER: { + Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter, + key=Attribute.GAS_METER, name="Gas Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.MEASUREMENT, ) ], - Attribute.gas_meter_calorific: [ + Attribute.GAS_METER_CALORIFIC: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_calorific, + key=Attribute.GAS_METER_CALORIFIC, name="Gas Meter Calorific", ) ], - Attribute.gas_meter_time: [ + Attribute.GAS_METER_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_time, + key=Attribute.GAS_METER_TIME, name="Gas Meter Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], - Attribute.gas_meter_volume: [ + Attribute.GAS_METER_VOLUME: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_volume, + key=Attribute.GAS_METER_VOLUME, name="Gas Meter Volume", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, @@ -302,114 +322,117 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - Capability.illuminance_measurement: { - Attribute.illuminance: [ + # no fixtures + Capability.ILLUMINANCE_MEASUREMENT: { + Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( - key=Attribute.illuminance, + key=Attribute.ILLUMINANCE, name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.infrared_level: { - Attribute.infrared_level: [ + # no fixtures + Capability.INFRARED_LEVEL: { + Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.infrared_level, + key=Attribute.INFRARED_LEVEL, name="Infrared Level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.media_input_source: { - Attribute.input_source: [ + Capability.MEDIA_INPUT_SOURCE: { + Attribute.INPUT_SOURCE: [ SmartThingsSensorEntityDescription( - key=Attribute.input_source, + key=Attribute.INPUT_SOURCE, name="Media Input Source", ) ] }, - Capability.media_playback_repeat: { - Attribute.playback_repeat_mode: [ + # part of the proposed spec, no fixtures + Capability.MEDIA_PLAYBACK_REPEAT: { + Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_repeat_mode, + key=Attribute.PLAYBACK_REPEAT_MODE, name="Media Playback Repeat", ) ] }, - Capability.media_playback_shuffle: { - Attribute.playback_shuffle: [ + # part of the proposed spec, no fixtures + Capability.MEDIA_PLAYBACK_SHUFFLE: { + Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_shuffle, + key=Attribute.PLAYBACK_SHUFFLE, name="Media Playback Shuffle", ) ] }, - Capability.media_playback: { - Attribute.playback_status: [ + Capability.MEDIA_PLAYBACK: { + Attribute.PLAYBACK_STATUS: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_status, + key=Attribute.PLAYBACK_STATUS, name="Media Playback Status", ) ] }, - Capability.odor_sensor: { - Attribute.odor_level: [ + Capability.ODOR_SENSOR: { + Attribute.ODOR_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.odor_level, + key=Attribute.ODOR_LEVEL, name="Odor Sensor", ) ] }, - Capability.oven_mode: { - Attribute.oven_mode: [ + Capability.OVEN_MODE: { + Attribute.OVEN_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_mode, + key=Attribute.OVEN_MODE, name="Oven Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.oven_operating_state: { - Attribute.machine_state: [ + Capability.OVEN_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Oven Machine State", ) ], - Attribute.oven_job_state: [ + Attribute.OVEN_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_job_state, + key=Attribute.OVEN_JOB_STATE, name="Oven Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Oven Completion Time", ) ], }, - Capability.oven_setpoint: { - Attribute.oven_setpoint: [ + Capability.OVEN_SETPOINT: { + Attribute.OVEN_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_setpoint, + key=Attribute.OVEN_SETPOINT, name="Oven Set Point", ) ] }, - Capability.power_consumption_report: { - Attribute.power_consumption: [ + Capability.POWER_CONSUMPTION_REPORT: { + Attribute.POWER_CONSUMPTION: [ SmartThingsSensorEntityDescription( key="energy_meter", name="energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 if (val := value.get("energy")) is not None else None - ), + value_fn=lambda value: value["energy"] / 1000, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -417,7 +440,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, - value_fn=lambda value: value.get("power"), + value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, ), SmartThingsSensorEntityDescription( @@ -426,11 +449,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("deltaEnergy")) is not None - else None - ), + value_fn=lambda value: value["deltaEnergy"] / 1000, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -438,11 +457,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("powerEnergy")) is not None - else None - ), + value_fn=lambda value: value["powerEnergy"] / 1000, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -450,18 +465,14 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("energySaved")) is not None - else None - ), + value_fn=lambda value: value["energySaved"] / 1000, ), ] }, - Capability.power_meter: { - Attribute.power: [ + Capability.POWER_METER: { + Attribute.POWER: [ SmartThingsSensorEntityDescription( - key=Attribute.power, + key=Attribute.POWER, name="Power Meter", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -469,72 +480,76 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.power_source: { - Attribute.power_source: [ + # no fixtures + Capability.POWER_SOURCE: { + Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( - key=Attribute.power_source, + key=Attribute.POWER_SOURCE, name="Power Source", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.refrigeration_setpoint: { - Attribute.refrigeration_setpoint: [ + # part of the proposed spec + Capability.REFRIGERATION_SETPOINT: { + Attribute.REFRIGERATION_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.refrigeration_setpoint, + key=Attribute.REFRIGERATION_SETPOINT, name="Refrigeration Setpoint", + device_class=SensorDeviceClass.TEMPERATURE, ) ] }, - Capability.relative_humidity_measurement: { - Attribute.humidity: [ + Capability.RELATIVE_HUMIDITY_MEASUREMENT: { + Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( - key=Attribute.humidity, - name="Relative Humidity", + key=Attribute.HUMIDITY, + name="Relative Humidity Measurement", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.robot_cleaner_cleaning_mode: { - Attribute.robot_cleaner_cleaning_mode: [ + Capability.ROBOT_CLEANER_CLEANING_MODE: { + Attribute.ROBOT_CLEANER_CLEANING_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_cleaning_mode, + key=Attribute.ROBOT_CLEANER_CLEANING_MODE, name="Robot Cleaner Cleaning Mode", entity_category=EntityCategory.DIAGNOSTIC, ) - ] + ], }, - Capability.robot_cleaner_movement: { - Attribute.robot_cleaner_movement: [ + Capability.ROBOT_CLEANER_MOVEMENT: { + Attribute.ROBOT_CLEANER_MOVEMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_movement, + key=Attribute.ROBOT_CLEANER_MOVEMENT, name="Robot Cleaner Movement", ) ] }, - Capability.robot_cleaner_turbo_mode: { - Attribute.robot_cleaner_turbo_mode: [ + Capability.ROBOT_CLEANER_TURBO_MODE: { + Attribute.ROBOT_CLEANER_TURBO_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_turbo_mode, + key=Attribute.ROBOT_CLEANER_TURBO_MODE, name="Robot Cleaner Turbo Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.signal_strength: { - Attribute.lqi: [ + # no fixtures + Capability.SIGNAL_STRENGTH: { + Attribute.LQI: [ SmartThingsSensorEntityDescription( - key=Attribute.lqi, + key=Attribute.LQI, name="LQI Signal Strength", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ) ], - Attribute.rssi: [ + Attribute.RSSI: [ SmartThingsSensorEntityDescription( - key=Attribute.rssi, + key=Attribute.RSSI, name="RSSI Signal Strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -542,85 +557,99 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - Capability.smoke_detector: { - Attribute.smoke: [ + # no fixtures + Capability.SMOKE_DETECTOR: { + Attribute.SMOKE: [ SmartThingsSensorEntityDescription( - key=Attribute.smoke, + key=Attribute.SMOKE, name="Smoke Detector", ) ] }, - Capability.temperature_measurement: { - Attribute.temperature: [ + Capability.TEMPERATURE_MEASUREMENT: { + Attribute.TEMPERATURE: [ SmartThingsSensorEntityDescription( - key=Attribute.temperature, + key=Attribute.TEMPERATURE, name="Temperature Measurement", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.thermostat_cooling_setpoint: { - Attribute.cooling_setpoint: [ + Capability.THERMOSTAT_COOLING_SETPOINT: { + Attribute.COOLING_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.cooling_setpoint, + key=Attribute.COOLING_SETPOINT, name="Thermostat Cooling Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + capability_ignore_list=[ + { + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.AIR_CONDITIONER_MODE, + }, + THERMOSTAT_CAPABILITIES, + ], ) ] }, - Capability.thermostat_fan_mode: { - Attribute.thermostat_fan_mode: [ + # no fixtures + Capability.THERMOSTAT_FAN_MODE: { + Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_fan_mode, + key=Attribute.THERMOSTAT_FAN_MODE, name="Thermostat Fan Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_heating_setpoint: { - Attribute.heating_setpoint: [ + # no fixtures + Capability.THERMOSTAT_HEATING_SETPOINT: { + Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.heating_setpoint, + key=Attribute.HEATING_SETPOINT, name="Thermostat Heating Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_mode: { - Attribute.thermostat_mode: [ + # no fixtures + Capability.THERMOSTAT_MODE: { + Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_mode, + key=Attribute.THERMOSTAT_MODE, name="Thermostat Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_operating_state: { - Attribute.thermostat_operating_state: [ + # no fixtures + Capability.THERMOSTAT_OPERATING_STATE: { + Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_operating_state, + key=Attribute.THERMOSTAT_OPERATING_STATE, name="Thermostat Operating State", + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_setpoint: { - Attribute.thermostat_setpoint: [ + # deprecated capability + Capability.THERMOSTAT_SETPOINT: { + Attribute.THERMOSTAT_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_setpoint, + key=Attribute.THERMOSTAT_SETPOINT, name="Thermostat Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.three_axis: { - Attribute.three_axis: [ + Capability.THREE_AXIS: { + Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( key="X Coordinate", name="X Coordinate", @@ -641,75 +670,77 @@ CAPABILITY_TO_SENSORS: dict[ ), ] }, - Capability.tv_channel: { - Attribute.tv_channel: [ + Capability.TV_CHANNEL: { + Attribute.TV_CHANNEL: [ SmartThingsSensorEntityDescription( - key=Attribute.tv_channel, + key=Attribute.TV_CHANNEL, name="Tv Channel", ) ], - Attribute.tv_channel_name: [ + Attribute.TV_CHANNEL_NAME: [ SmartThingsSensorEntityDescription( - key=Attribute.tv_channel_name, + key=Attribute.TV_CHANNEL_NAME, name="Tv Channel Name", ) ], }, - Capability.tvoc_measurement: { - Attribute.tvoc_level: [ + # no fixtures + Capability.TVOC_MEASUREMENT: { + Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.tvoc_level, + key=Attribute.TVOC_LEVEL, name="Tvoc Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.ultraviolet_index: { - Attribute.ultraviolet_index: [ + # no fixtures + Capability.ULTRAVIOLET_INDEX: { + Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( - key=Attribute.ultraviolet_index, + key=Attribute.ULTRAVIOLET_INDEX, name="Ultraviolet Index", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.voltage_measurement: { - Attribute.voltage: [ + Capability.VOLTAGE_MEASUREMENT: { + Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( - key=Attribute.voltage, + key=Attribute.VOLTAGE, name="Voltage Measurement", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.washer_mode: { - Attribute.washer_mode: [ + # part of the proposed spec + Capability.WASHER_MODE: { + Attribute.WASHER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.washer_mode, + key=Attribute.WASHER_MODE, name="Washer Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.washer_operating_state: { - Attribute.machine_state: [ + Capability.WASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Washer Machine State", ) ], - Attribute.washer_job_state: [ + Attribute.WASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.washer_job_state, + key=Attribute.WASHER_JOB_STATE, name="Washer Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Washer Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, @@ -718,37 +749,37 @@ CAPABILITY_TO_SENSORS: dict[ }, } + UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, - "mG": None, # Three axis sensors never had a unit, so this removes it for now + "mG": None, } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsSensor(device, attribute, description) - for device in broker.devices.values() - for capability in broker.get_assigned(device.device_id, "sensor") - for attribute, descriptions in CAPABILITY_TO_SENSORS[capability].items() - for description in descriptions + SmartThingsSensor(entry_data.client, device, description, capability, attribute) + for device in entry_data.devices.values() + for capability, attributes in device.status[MAIN].items() + if capability in CAPABILITY_TO_SENSORS + for attribute in attributes + for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) + if not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_SENSORS if capability in capabilities - ] - - class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" @@ -756,28 +787,30 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): def __init__( self, - device: DeviceEntity, - attribute: str, + client: SmartThings, + device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + capability: Capability, + attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, {capability}) + self._attr_name = f"{device.device.label} {entity_description.name}" + self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute - self._attr_name = f"{device.label} {entity_description.name}" - self._attr_unique_id = f"{device.device_id}{entity_description.unique_id_separator}{entity_description.key}" + self.capability = capability self.entity_description = entity_description @property - def native_value(self) -> str | float | int | datetime | None: + def native_value(self) -> str | float | datetime | int | None: """Return the state of the sensor.""" - return self.entity_description.value_fn( - self._device.status.attributes[self._attribute].value - ) + res = self.get_attribute_value(self.capability, self._attribute) + return self.entity_description.value_fn(res) @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._device.status.attributes[self._attribute].unit + unit = self._internal_state[self.capability][self._attribute].unit return ( UNITS.get(unit, unit) if unit @@ -789,6 +822,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Return the state attributes.""" if self.entity_description.extra_state_attributes_fn: return self.entity_description.extra_state_attributes_fn( - self._device.status + self.get_attribute_value(self.capability, self._attribute) ) return None diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py deleted file mode 100644 index 76b6804075f..00000000000 --- a/homeassistant/components/smartthings/smartapp.py +++ /dev/null @@ -1,545 +0,0 @@ -"""SmartApp functionality to receive cloud-push notifications.""" - -from __future__ import annotations - -import asyncio -import functools -import logging -import secrets -from typing import Any -from urllib.parse import urlparse -from uuid import uuid4 - -from aiohttp import web -from pysmartapp import Dispatcher, SmartAppManager -from pysmartapp.const import SETTINGS_APP_ID -from pysmartthings import ( - APP_TYPE_WEBHOOK, - CAPABILITIES, - CLASSIFICATION_AUTOMATION, - App, - AppEntity, - AppOAuth, - AppSettings, - InstalledAppStatus, - SmartThings, - SourceType, - Subscription, - SubscriptionEntity, -) - -from homeassistant.components import cloud, webhook -from homeassistant.config_entries import ConfigFlowResult -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.storage import Store - -from .const import ( - APP_NAME_PREFIX, - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - IGNORED_CAPABILITIES, - SETTINGS_INSTANCE_ID, - SIGNAL_SMARTAPP_PREFIX, - STORAGE_KEY, - STORAGE_VERSION, - SUBSCRIPTION_WARNING_LIMIT, -) - -_LOGGER = logging.getLogger(__name__) - - -def format_unique_id(app_id: str, location_id: str) -> str: - """Format the unique id for a config entry.""" - return f"{app_id}_{location_id}" - - -async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None: - """Find an existing SmartApp for this installation of hass.""" - apps = await api.apps() - for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]: - # Load settings to compare instance id - settings = await app.settings() - if ( - settings.settings.get(SETTINGS_INSTANCE_ID) - == hass.data[DOMAIN][CONF_INSTANCE_ID] - ): - return app - return None - - -async def validate_installed_app(api, installed_app_id: str): - """Ensure the specified installed SmartApp is valid and functioning. - - Query the API for the installed SmartApp and validate that it is tied to - the specified app_id and is in an authorized state. - """ - installed_app = await api.installed_app(installed_app_id) - if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED: - raise RuntimeWarning( - f"Installed SmartApp instance '{installed_app.display_name}' " - f"({installed_app.installed_app_id}) is not AUTHORIZED " - f"but instead {installed_app.installed_app_status}" - ) - return installed_app - - -def validate_webhook_requirements(hass: HomeAssistant) -> bool: - """Ensure Home Assistant is setup properly to receive webhooks.""" - if cloud.async_active_subscription(hass): - return True - if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None: - return True - return get_webhook_url(hass).lower().startswith("https://") - - -def get_webhook_url(hass: HomeAssistant) -> str: - """Get the URL of the webhook. - - Return the cloudhook if available, otherwise local webhook. - """ - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloud.async_active_subscription(hass) and cloudhook_url is not None: - return cloudhook_url - return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - - -def _get_app_template(hass: HomeAssistant): - try: - endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}" - except NoURLAvailableError: - endpoint = "" - - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url is not None: - endpoint = "via Nabu Casa" - description = f"{hass.config.location_name} {endpoint}" - - return { - "app_name": APP_NAME_PREFIX + str(uuid4()), - "display_name": "Home Assistant", - "description": description, - "webhook_target_url": get_webhook_url(hass), - "app_type": APP_TYPE_WEBHOOK, - "single_instance": True, - "classifications": [CLASSIFICATION_AUTOMATION], - } - - -async def create_app(hass: HomeAssistant, api): - """Create a SmartApp for this instance of hass.""" - # Create app from template attributes - template = _get_app_template(hass) - app = App() - for key, value in template.items(): - setattr(app, key, value) - app, client = await api.create_app(app) - _LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id) - - # Set unique hass id in settings - settings = AppSettings(app.app_id) - settings.settings[SETTINGS_APP_ID] = app.app_id - settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID] - await api.update_app_settings(settings) - _LOGGER.debug( - "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id - ) - - # Set oauth scopes - oauth = AppOAuth(app.app_id) - oauth.client_name = APP_OAUTH_CLIENT_NAME - oauth.scope.extend(APP_OAUTH_SCOPES) - await api.update_app_oauth(oauth) - _LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id) - return app, client - - -async def update_app(hass: HomeAssistant, app): - """Ensure the SmartApp is up-to-date and update if necessary.""" - template = _get_app_template(hass) - template.pop("app_name") # don't update this - update_required = False - for key, value in template.items(): - if getattr(app, key) != value: - update_required = True - setattr(app, key, value) - if update_required: - await app.save() - _LOGGER.debug( - "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id - ) - - -def setup_smartapp(hass, app): - """Configure an individual SmartApp in hass. - - Register the SmartApp with the SmartAppManager so that hass will service - lifecycle events (install, event, etc...). A unique SmartApp is created - for each SmartThings account that is configured in hass. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - if smartapp := manager.smartapps.get(app.app_id): - # already setup - return smartapp - smartapp = manager.register(app.app_id, app.webhook_public_key) - smartapp.name = app.display_name - smartapp.description = app.description - smartapp.permissions.extend(APP_OAUTH_SCOPES) - return smartapp - - -async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool): - """Configure the SmartApp webhook in hass. - - SmartApps are an extension point within the SmartThings ecosystem and - is used to receive push updates (i.e. device updates) from the cloud. - """ - if hass.data.get(DOMAIN): - # already setup - if not fresh_install: - return - - # We're doing a fresh install, clean up - await unload_smartapp_endpoint(hass) - - # Get/create config to store a unique id for this hass instance. - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - - if fresh_install or not (config := await store.async_load()): - # Create config - config = { - CONF_INSTANCE_ID: str(uuid4()), - CONF_WEBHOOK_ID: secrets.token_hex(), - CONF_CLOUDHOOK_URL: None, - } - await store.async_save(config) - - # Register webhook - webhook.async_register( - hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook - ) - - # Create webhook if eligible - cloudhook_url = config.get(CONF_CLOUDHOOK_URL) - if ( - cloudhook_url is None - and cloud.async_active_subscription(hass) - and not hass.config_entries.async_entries(DOMAIN) - ): - cloudhook_url = await cloud.async_create_cloudhook( - hass, config[CONF_WEBHOOK_ID] - ) - config[CONF_CLOUDHOOK_URL] = cloudhook_url - await store.async_save(config) - _LOGGER.debug("Created cloudhook '%s'", cloudhook_url) - - # SmartAppManager uses a dispatcher to invoke callbacks when push events - # occur. Use hass' implementation instead of the built-in one. - dispatcher = Dispatcher( - signal_prefix=SIGNAL_SMARTAPP_PREFIX, - connect=functools.partial(async_dispatcher_connect, hass), - send=functools.partial(async_dispatcher_send, hass), - ) - # Path is used in digital signature validation - path = ( - urlparse(cloudhook_url).path - if cloudhook_url - else webhook.async_generate_path(config[CONF_WEBHOOK_ID]) - ) - manager = SmartAppManager(path, dispatcher=dispatcher) - manager.connect_install(functools.partial(smartapp_install, hass)) - manager.connect_update(functools.partial(smartapp_update, hass)) - manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) - - hass.data[DOMAIN] = { - DATA_MANAGER: manager, - CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], - DATA_BROKERS: {}, - CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], - # Will not be present if not enabled - CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), - } - _LOGGER.debug( - "Setup endpoint for %s", - cloudhook_url - if cloudhook_url - else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]), - ) - - -async def unload_smartapp_endpoint(hass: HomeAssistant): - """Tear down the component configuration.""" - if DOMAIN not in hass.data: - return - # Remove the cloudhook if it was created - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url and cloud.async_is_logged_in(hass): - await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Remove cloudhook from storage - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - await store.async_save( - { - CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID], - CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID], - CONF_CLOUDHOOK_URL: None, - } - ) - _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url) - # Remove the webhook - webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Disconnect all brokers - for broker in hass.data[DOMAIN][DATA_BROKERS].values(): - broker.disconnect() - # Remove all handlers from manager - hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all() - # Remove the component data - hass.data.pop(DOMAIN) - - -async def smartapp_sync_subscriptions( - hass: HomeAssistant, - auth_token: str, - location_id: str, - installed_app_id: str, - devices, -): - """Synchronize subscriptions of an installed up.""" - api = SmartThings(async_get_clientsession(hass), auth_token) - tasks = [] - - async def create_subscription(target: str): - sub = Subscription() - sub.installed_app_id = installed_app_id - sub.location_id = location_id - sub.source_type = SourceType.CAPABILITY - sub.capability = target - try: - await api.create_subscription(sub) - _LOGGER.debug( - "Created subscription for '%s' under app '%s'", target, installed_app_id - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to create subscription for '%s' under app '%s': %s", - target, - installed_app_id, - error, - ) - - async def delete_subscription(sub: SubscriptionEntity): - try: - await api.delete_subscription(installed_app_id, sub.subscription_id) - _LOGGER.debug( - ( - "Removed subscription for '%s' under app '%s' because it was no" - " longer needed" - ), - sub.capability, - installed_app_id, - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to remove subscription for '%s' under app '%s': %s", - sub.capability, - installed_app_id, - error, - ) - - # Build set of capabilities and prune unsupported ones - capabilities = set() - for device in devices: - capabilities.update(device.capabilities) - # Remove items not defined in the library - capabilities.intersection_update(CAPABILITIES) - # Remove unused capabilities - capabilities.difference_update(IGNORED_CAPABILITIES) - capability_count = len(capabilities) - if capability_count > SUBSCRIPTION_WARNING_LIMIT: - _LOGGER.warning( - ( - "Some device attributes may not receive push updates and there may be" - " subscription creation failures under app '%s' because %s" - " subscriptions are required but there is a limit of %s per app" - ), - installed_app_id, - capability_count, - SUBSCRIPTION_WARNING_LIMIT, - ) - _LOGGER.debug( - "Synchronizing subscriptions for %s capabilities under app '%s': %s", - capability_count, - installed_app_id, - capabilities, - ) - - # Get current subscriptions and find differences - subscriptions = await api.subscriptions(installed_app_id) - for subscription in subscriptions: - if subscription.capability in capabilities: - capabilities.remove(subscription.capability) - else: - # Delete the subscription - tasks.append(delete_subscription(subscription)) - - # Remaining capabilities need subscriptions created - tasks.extend([create_subscription(c) for c in capabilities]) - - if tasks: - await asyncio.gather(*tasks) - else: - _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) - - -async def _find_and_continue_flow( - hass: HomeAssistant, - app_id: str, - location_id: str, - installed_app_id: str, - refresh_token: str, -): - """Continue a config flow if one is in progress for the specific installed app.""" - unique_id = format_unique_id(app_id, location_id) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"].get("unique_id") == unique_id - ), - None, - ) - if flow is not None: - await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) - - -async def _continue_flow( - hass: HomeAssistant, - app_id: str, - installed_app_id: str, - refresh_token: str, - flow: ConfigFlowResult, -) -> None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) - - -async def smartapp_install(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp installation and continue the config flow.""" - await _find_and_continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Installed SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_update(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp update and either update the entry or continue the flow.""" - unique_id = format_unique_id(app.app_id, req.location_id) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"].get("unique_id") == unique_id - and flow["step_id"] == "authorize" - ), - None, - ) - if flow is not None: - await _continue_flow( - hass, app.app_id, req.installed_app_id, req.refresh_token, flow - ) - _LOGGER.debug( - "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - req.installed_app_id, - app.app_id, - ) - return - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} - ) - _LOGGER.debug( - "Updated config entry '%s' for SmartApp '%s' under parent app '%s'", - entry.entry_id, - req.installed_app_id, - app.app_id, - ) - - await _find_and_continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id - ) - - -async def smartapp_uninstall(hass: HomeAssistant, req, resp, app): - """Handle when a SmartApp is removed from a location by the user. - - Find and delete the config entry representing the integration. - """ - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - # Add as job not needed because the current coroutine was invoked - # from the dispatcher and is not being awaited. - await hass.config_entries.async_remove(entry.entry_id) - - _LOGGER.debug( - "Uninstalled SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request): - """Handle a smartapp lifecycle event callback from SmartThings. - - Requests from SmartThings are digitally signed and the SmartAppManager - validates the signature for authenticity. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - data = await request.json() - result = await manager.handle_request(data, request.headers) - return web.json_response(result) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 31a552be149..5112d819026 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -1,43 +1,29 @@ { "config": { "step": { - "user": { - "title": "Confirm Callback URL", - "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again." + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pat": { - "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", - "data": { - "access_token": "[%key:common::config_flow::data::access_token%]" - } - }, - "select_location": { - "title": "Select Location", - "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", - "data": { "location_id": "[%key:common::config_flow::data::location%]" } - }, - "authorize": { "title": "Authorize Home Assistant" }, "reauth_confirm": { - "title": "Reauthorize Home Assistant", - "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." - }, - "update_confirm": { - "title": "Finish reauthentication", - "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The SmartThings integration needs to re-authenticate your account" } }, - "abort": { - "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", - "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." - }, "error": { - "token_invalid_format": "The token must be in the UID/GUID format", - "token_unauthorized": "The token is invalid or no longer authorized.", - "token_forbidden": "The token does not have the required OAuth scopes.", - "app_setup_error": "Unable to set up the SmartApp. Please try again.", - "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "abort": { + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 7a88ca0c422..d8cd9f1f956 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,60 +2,67 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.FAN_SPEED, +) + +AC_CAPABILITIES = ( + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "switch") + SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and not any(capability in device.status[MAIN] for capability in CAPABILITIES) + and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - # Must be able to be turned on/off. - if Capability.switch in capabilities: - return [Capability.switch, Capability.energy_meter, Capability.power_meter] - return None - - class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 08fe28e4df5..b891e807a7f 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -28,6 +28,7 @@ APPLICATION_CREDENTIALS = [ "onedrive", "point", "senz", + "smartthings", "spotify", "tesla_fleet", "twitch", diff --git a/requirements_all.txt b/requirements_all.txt index 40df67dc93f..54c0a29bee5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,10 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==1.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 029b770512e..a3f171fa1a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,10 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==1.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 5a3e9135963..94a2e7512f2 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -1 +1,75 @@ -"""Tests for the SmartThings component.""" +"""Tests for the SmartThings integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from pysmartthings.models import Attribute, Capability, DeviceEvent +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def snapshot_smartthings_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot SmartThings entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +def set_attribute_value( + mock: AsyncMock, + capability: Capability, + attribute: Attribute, + value: Any, + component: str = MAIN, +) -> None: + """Set the value of an attribute.""" + mock.get_device_status.return_value[component][capability][attribute].value = value + + +async def trigger_update( + hass: HomeAssistant, + mock: AsyncMock, + device_id: str, + capability: Capability, + attribute: Attribute, + value: str | float | dict[str, Any] | list[Any] | None, + data: dict[str, Any] | None = None, +) -> None: + """Trigger an update.""" + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id and call[0][2] == capability: + call[0][3]( + DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + ) + await hass.async_block_till_done() diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 71a36c7885a..b7d0cb61607 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -1,358 +1,178 @@ """Test configuration and mocks for the SmartThings component.""" -import secrets -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch -from pysmartthings import ( - CLASSIFICATION_AUTOMATION, - AppEntity, - AppOAuthClient, - AppSettings, - DeviceEntity, +from pysmartthings.models import ( + DeviceResponse, DeviceStatus, - InstalledApp, - InstalledAppStatus, - InstalledAppType, - Location, - SceneEntity, - SmartThings, - Subscription, + LocationResponse, + SceneResponse, ) -from pysmartthings.api import Api import pytest -from homeassistant.components import webhook -from homeassistant.components.smartthings import DeviceBroker +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.smartthings import CONF_INSTALLED_APP_ID from homeassistant.components.smartthings.const import ( - APP_NAME_PREFIX, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, - DATA_BROKERS, DOMAIN, - SETTINGS_INSTANCE_ID, - STORAGE_KEY, - STORAGE_VERSION, -) -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, + SCOPES, ) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.light.conftest import mock_light_profiles # noqa: F401 - -COMPONENT_PREFIX = "homeassistant.components.smartthings." +from tests.common import MockConfigEntry, load_fixture -async def setup_platform( - hass: HomeAssistant, platform: str, *, devices=None, scenes=None -): - """Set up the SmartThings platform and prerequisites.""" - hass.config.components.add(DOMAIN) - config_entry = MockConfigEntry( - version=2, - domain=DOMAIN, - title="Test", - data={CONF_INSTALLED_APP_ID: str(uuid4())}, - ) - config_entry.add_to_hass(hass) - broker = DeviceBroker( - hass, config_entry, Mock(), Mock(), devices or [], scenes or [] - ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.smartthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry - hass.data[DOMAIN] = {DATA_BROKERS: {config_entry.entry_id: broker}} - config_entry.mock_state(hass, ConfigEntryState.LOADED) - await hass.config_entries.async_forward_entry_setups(config_entry, [platform]) - await hass.async_block_till_done() - return config_entry + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 @pytest.fixture(autouse=True) -async def setup_component( - hass: HomeAssistant, config_file: dict[str, str], hass_storage: dict[str, Any] -) -> None: - """Load the SmartThing component.""" - hass_storage[STORAGE_KEY] = {"data": config_file, "version": STORAGE_VERSION} - await async_process_ha_core_config( +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( hass, - {"external_url": "https://test.local"}, - ) - await async_setup_component(hass, "smartthings", {}) - - -def _create_location() -> Mock: - loc = Mock(Location) - loc.name = "Test Location" - loc.location_id = str(uuid4()) - return loc - - -@pytest.fixture(name="location") -def location_fixture() -> Mock: - """Fixture for a single location.""" - return _create_location() - - -@pytest.fixture(name="locations") -def locations_fixture(location: Mock) -> list[Mock]: - """Fixture for 2 locations.""" - return [location, _create_location()] - - -@pytest.fixture(name="app") -async def app_fixture(hass: HomeAssistant, config_file: dict[str, str]) -> Mock: - """Fixture for a single app.""" - app = Mock(AppEntity) - app.app_name = APP_NAME_PREFIX + str(uuid4()) - app.app_id = str(uuid4()) - app.app_type = "WEBHOOK_SMART_APP" - app.classifications = [CLASSIFICATION_AUTOMATION] - app.display_name = "Home Assistant" - app.description = f"{hass.config.location_name} at https://test.local" - app.single_instance = True - app.webhook_target_url = webhook.async_generate_url( - hass, hass.data[DOMAIN][CONF_WEBHOOK_ID] + DOMAIN, + ClientCredential("CLIENT_ID", "CLIENT_SECRET"), + DOMAIN, ) - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - app.settings.return_value = settings - return app - -@pytest.fixture(name="app_oauth_client") -def app_oauth_client_fixture() -> Mock: - """Fixture for a single app's oauth.""" - client = Mock(AppOAuthClient) - client.client_id = str(uuid4()) - client.client_secret = str(uuid4()) - return client - - -@pytest.fixture(name="app_settings") -def app_settings_fixture(app, config_file): - """Fixture for an app settings.""" - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - return settings - - -def _create_installed_app(location_id: str, app_id: str) -> Mock: - item = Mock(InstalledApp) - item.installed_app_id = str(uuid4()) - item.installed_app_status = InstalledAppStatus.AUTHORIZED - item.installed_app_type = InstalledAppType.WEBHOOK_SMART_APP - item.app_id = app_id - item.location_id = location_id - return item - - -@pytest.fixture(name="installed_app") -def installed_app_fixture(location: Mock, app: Mock) -> Mock: - """Fixture for a single installed app.""" - return _create_installed_app(location.location_id, app.app_id) - - -@pytest.fixture(name="installed_apps") -def installed_apps_fixture(installed_app, locations, app): - """Fixture for 2 installed apps.""" - return [installed_app, _create_installed_app(locations[1].location_id, app.app_id)] - - -@pytest.fixture(name="config_file") -def config_file_fixture() -> dict[str, str]: - """Fixture representing the local config file contents.""" - return {CONF_INSTANCE_ID: str(uuid4()), CONF_WEBHOOK_ID: secrets.token_hex()} - - -@pytest.fixture(name="smartthings_mock") -def smartthings_mock_fixture(locations): - """Fixture to mock smartthings API calls.""" - - async def _location(location_id): - return next( - location for location in locations if location.location_id == location_id - ) - - smartthings_mock = Mock(SmartThings) - smartthings_mock.location.side_effect = _location - mock = Mock(return_value=smartthings_mock) +@pytest.fixture +def mock_smartthings() -> Generator[AsyncMock]: + """Mock a SmartThings client.""" with ( - patch(COMPONENT_PREFIX + "SmartThings", new=mock), - patch(COMPONENT_PREFIX + "config_flow.SmartThings", new=mock), - patch(COMPONENT_PREFIX + "smartapp.SmartThings", new=mock), + patch( + "homeassistant.components.smartthings.SmartThings", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.smartthings.config_flow.SmartThings", + new=mock_client, + ), ): - yield smartthings_mock + client = mock_client.return_value + client.get_scenes.return_value = SceneResponse.from_json( + load_fixture("scenes.json", DOMAIN) + ).items + client.get_locations.return_value = LocationResponse.from_json( + load_fixture("locations.json", DOMAIN) + ).items + yield client -@pytest.fixture(name="device") -def device_fixture(location): - """Fixture representing devices loaded.""" - item = Mock(DeviceEntity) - item.device_id = "743de49f-036f-4e9c-839a-2f89d57607db" - item.name = "GE In-Wall Smart Dimmer" - item.label = "Front Porch Lights" - item.location_id = location.location_id - item.capabilities = [ - "switch", - "switchLevel", - "refresh", - "indicator", - "sensor", - "actuator", - "healthCheck", - "light", +@pytest.fixture( + params=[ + "da_ac_rac_000001", + "da_ac_rac_01001", + "multipurpose_sensor", + "contact_sensor", + "base_electric_meter", + "smart_plug", + "vd_stv_2017_k", + "c2c_arlo_pro_3_switch", + "yale_push_button_deadbolt_lock", + "ge_in_wall_smart_dimmer", + "centralite", + "da_ref_normal_000001", + "vd_network_audio_002s", + "iphone", + "da_wm_dw_000001", + "da_wm_wd_000001", + "da_wm_wm_000001", + "da_rvc_normal_000001", + "da_ks_microwave_0101x", + "hue_color_temperature_bulb", + "hue_rgbw_color_bulb", + "c2c_shade", + "sonos_player", + "aeotec_home_energy_meter_gen5", + "virtual_water_sensor", + "virtual_thermostat", + "virtual_valve", + "sensibo_airconditioner_1", + "ecobee_sensor", + "ecobee_thermostat", + "fake_fan", ] - item.components = {"main": item.capabilities} - item.status = Mock(DeviceStatus) - return item +) +def device_fixture( + mock_smartthings: AsyncMock, request: pytest.FixtureRequest +) -> Generator[str]: + """Return every device.""" + return request.param -@pytest.fixture(name="config_entry") -def config_entry_fixture(installed_app: Mock, location: Mock) -> MockConfigEntry: - """Fixture representing a config entry.""" - data = { - CONF_ACCESS_TOKEN: str(uuid4()), - CONF_INSTALLED_APP_ID: installed_app.installed_app_id, - CONF_APP_ID: installed_app.app_id, - CONF_LOCATION_ID: location.location_id, - CONF_REFRESH_TOKEN: str(uuid4()), - CONF_CLIENT_ID: str(uuid4()), - CONF_CLIENT_SECRET: str(uuid4()), - } +@pytest.fixture +def devices(mock_smartthings: AsyncMock, device_fixture: str) -> Generator[AsyncMock]: + """Return a specific device.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture(f"devices/{device_fixture}.json", DOMAIN) + ).items + mock_smartthings.get_device_status.return_value = DeviceStatus.from_json( + load_fixture(f"device_status/{device_fixture}.json", DOMAIN) + ).components + return mock_smartthings + + +@pytest.fixture +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Mock a config entry.""" return MockConfigEntry( domain=DOMAIN, - data=data, - title=location.name, - version=2, - source=SOURCE_USER, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, ) -@pytest.fixture(name="subscription_factory") -def subscription_factory_fixture(): - """Fixture for creating mock subscriptions.""" - - def _factory(capability): - sub = Subscription() - sub.capability = capability - return sub - - return _factory - - -@pytest.fixture(name="device_factory") -def device_factory_fixture(): - """Fixture for creating mock devices.""" - api = Mock(Api) - api.post_device_command.return_value = {"results": [{"status": "ACCEPTED"}]} - - def _factory(label, capabilities, status: dict | None = None): - device_data = { - "deviceId": str(uuid4()), - "name": "Device Type Handler Name", - "label": label, - "deviceManufacturerCode": "9135fc86-0929-4436-bf73-5d75f523d9db", - "locationId": "fcd829e9-82f4-45b9-acfd-62fda029af80", - "components": [ - { - "id": "main", - "capabilities": [ - {"id": capability, "version": 1} for capability in capabilities - ], - } - ], - "dth": { - "deviceTypeId": "b678b29d-2726-4e4f-9c3f-7aa05bd08964", - "deviceTypeName": "Switch", - "deviceNetworkType": "ZWAVE", - }, - "type": "DTH", - } - device = DeviceEntity(api, data=device_data) - if status: - for attribute, value in status.items(): - device.status.apply_attribute_update("main", "", attribute, value) - return device - - return _factory - - -@pytest.fixture(name="scene_factory") -def scene_factory_fixture(location): - """Fixture for creating mock devices.""" - - def _factory(name): - scene = Mock(SceneEntity) - scene.scene_id = str(uuid4()) - scene.name = name - scene.icon = None - scene.color = None - scene.location_id = location.location_id - return scene - - return _factory - - -@pytest.fixture(name="scene") -def scene_fixture(scene_factory): - """Fixture for an individual scene.""" - return scene_factory("Test Scene") - - -@pytest.fixture(name="event_factory") -def event_factory_fixture(): - """Fixture for creating mock devices.""" - - def _factory( - device_id, - event_type="DEVICE_EVENT", - capability="", - attribute="Updated", - value="Value", - data=None, - ): - event = Mock() - event.event_type = event_type - event.device_id = device_id - event.component_id = "main" - event.capability = capability - event.attribute = attribute - event.value = value - event.data = data - event.location_id = str(uuid4()) - return event - - return _factory - - -@pytest.fixture(name="event_request_factory") -def event_request_factory_fixture(event_factory): - """Fixture for creating mock smartapp event requests.""" - - def _factory(device_ids=None, events=None): - request = Mock() - request.installed_app_id = uuid4() - if events is None: - events = [] - if device_ids: - events.extend([event_factory(device_id) for device_id in device_ids]) - events.append(event_factory(uuid4())) - events.append(event_factory(device_ids[0], event_type="OTHER")) - request.events = events - return request - - return _factory +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock the old config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + version=2, + ) diff --git a/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..95ae6310be8 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 2859.743, + "unit": "W", + "timestamp": "2025-02-10T21:09:08.228Z" + } + }, + "voltageMeasurement": { + "voltage": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null + } + }, + "energyMeter": { + "energy": { + "value": 19978.536, + "unit": "kWh", + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/base_electric_meter.json b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json new file mode 100644 index 00000000000..b4fa67b6f7e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 938.3, + "unit": "W", + "timestamp": "2025-02-09T17:56:21.748Z" + } + }, + "energyMeter": { + "energy": { + "value": 1930.362, + "unit": "kWh", + "timestamp": "2025-02-09T17:56:21.918Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..371a779f83c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json @@ -0,0 +1,82 @@ +{ + "components": { + "main": { + "videoCapture": { + "stream": { + "value": null + }, + "clip": { + "value": null + } + }, + "videoStream": { + "supportedFeatures": { + "value": null + }, + "stream": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-03T21:55:57.991Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "alarm": { + "alarm": { + "value": "off", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "refresh": {}, + "soundSensor": { + "sound": { + "value": "not detected", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T21:56:10.041Z" + }, + "type": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-08T21:56:10.041Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_shade.json b/tests/components/smartthings/fixtures/device_status/c2c_shade.json new file mode 100644 index 00000000000..cc5bcd84482 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_shade.json @@ -0,0 +1,50 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-07T23:01:15.966Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "refresh": {}, + "windowShade": { + "supportedWindowShadeCommands": { + "value": null + }, + "windowShade": { + "value": "open", + "timestamp": "2025-02-08T09:04:47.694Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/centralite.json b/tests/components/smartthings/fixtures/device_status/centralite.json new file mode 100644 index 00000000000..efdf54d9128 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/centralite.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 0.0, + "unit": "W", + "timestamp": "2025-02-09T17:49:15.190Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T17:49:15.112Z" + } + }, + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.783Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-01-26T10:19:54.788Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-01-26T10:19:54.789Z" + }, + "currentVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.775Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T17:24:16.864Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json new file mode 100644 index 00000000000..fa158d41b39 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -0,0 +1,66 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T17:16:42.674Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 59.0, + "unit": "F", + "timestamp": "2025-02-09T17:11:44.249Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T13:23:50.726Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "currentVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json new file mode 100644 index 00000000000..c80fcf9c298 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -0,0 +1,879 @@ +{ + "components": { + "1": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 0, + "unit": "%", + "timestamp": "2021-04-06T16:43:35.291Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + }, + "maximumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + }, + "airConditionerMode": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.686Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:57:57.602Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + }, + "acOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + } + }, + "switch": { + "switch": { + "value": null, + "timestamp": "2021-04-06T16:44:10.518Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": null, + "timestamp": "2021-04-06T16:44:10.498Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnfv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnhw": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "di": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "dmv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "n": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmo": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "vid": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmn": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnml": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnpv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnos": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "pi": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "icv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "audioVolume", + "custom.autoCleaningMode", + "custom.airConditionerTropicalNightMode", + "custom.airConditionerOdorController", + "demandResponseLoadControl", + "relativeHumidityMeasurement" + ], + "timestamp": "2024-09-10T10:26:28.605Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:44:10.325Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-08T00:44:53.247Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null, + "timestamp": "2021-04-06T16:44:10.373Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null, + "timestamp": "2021-04-06T16:43:59.136Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:54.748Z" + } + }, + "audioVolume": { + "volume": { + "value": null, + "unit": "%", + "timestamp": "2021-04-06T16:43:53.541Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2021-04-06T16:43:53.364Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": null, + "timestamp": "2021-04-06T16:43:53.344Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null, + "timestamp": "2021-04-06T16:43:38.992Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:39.097Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + } + }, + "custom.energyType": { + "energyType": { + "value": null, + "timestamp": "2021-04-06T16:43:38.843Z" + }, + "energySavingSupport": { + "value": null + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:38.529Z" + } + } + }, + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 60, + "unit": "%", + "timestamp": "2024-12-30T13:10:23.759Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-01-08T06:30:58.307Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto", "heat"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2021-12-29T01:36:51.289Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_KRAC_18K", + "timestamp": "2025-02-08T00:44:53.855Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:43:37.208Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": ["off", "windFree"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T16:37:54.072Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:43:35.933Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:43:35.912Z" + }, + "mnfv": { + "value": "0.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "di": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:43:35.803Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "n": { + "value": "[room a/c] Samsung", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmo": { + "value": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "vid": { + "value": "DA-AC-RAC-000001", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnpv": { + "value": "0G3MPDCKA00010E", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnos": { + "value": "TizenRT2.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "pi": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "samsungce.dongleSoftwareInstallation", + "demandResponseLoadControl", + "custom.airConditionerOdorController" + ], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24070101, + "timestamp": "2024-09-04T06:35:09.557Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:43:35.782Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T09:14:39.249Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T16:33:29.164Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T09:15:11.608Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["1"], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 2247300, + "deltaEnergy": 400, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 2247300, + "energySaved": 0, + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.temperature"], + "if": ["oic.if.baseline", "oic.if.a"], + "range": [16.0, 30.0], + "units": "C", + "temperature": 22.0 + } + }, + "data": { + "href": "/temperature/desired/0" + }, + "timestamp": "2023-07-19T03:07:43.270Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-09-04T06:35:09.557Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T00:44:53.349Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-08T00:44:53.549Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:35.379Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2021-12-29T07:29:17.526Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CEZFTFFL7Z2", + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.363Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json new file mode 100644 index 00000000000..257d553cb9f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -0,0 +1,731 @@ +{ + "components": { + "main": { + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": ["custom.spiMode.setSpiMode"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 42, + "unit": "%", + "timestamp": "2025-02-09T17:02:45.042Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": [], + "timestamp": "2025-02-09T14:35:56.800Z" + }, + "supportedAcModes": { + "value": ["auto", "cool", "dry", "wind", "heat"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "smart", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T05:44:01.853Z" + } + }, + "samsungce.airConditionerBeep": { + "beep": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ARA-WW-TP1-22-COMMON_11240702", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "di": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "n": { + "value": "Samsung-Room-Air-Conditioner", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnmo": { + "value": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "vid": { + "value": "DA-AC-RAC-01001", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "pi": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "samsungce.deviceInfoPrivate", + "samsungce.quickControl", + "samsungce.welcomeCooling", + "samsungce.airConditionerBeep", + "samsungce.airConditionerLighting", + "samsungce.individualControlLock", + "samsungce.alwaysOnSensing", + "samsungce.buttonDisplayCondition", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.spiMode", + "audioNotification" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100102, + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "010", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["fixed", "vertical", "horizontal", "all"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "audioVolume": { + "volume": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 13836, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 13836, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-09T16:08:15Z", + "end": "2025-02-09T17:02:44Z" + }, + "timestamp": "2025-02-09T17:02:44.883Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "on", + "timestamp": "2025-02-09T05:44:02.014Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": null + } + }, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungce.welcomeCooling": { + "latestRequestId": { + "value": null + }, + "operatingState": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start", "cancel"], + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "errors": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterUsage": { + "value": 12, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterCapacity": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterResetType": { + "value": ["replaceable", "washable"], + "timestamp": "2025-02-09T12:00:10.310Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2025-01-28T21:31:39.517Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.560Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-01-28T21:31:37.357Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.731Z" + } + }, + "bypassable": { + "bypassStatus": { + "value": "bypassed", + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "samsungce.airQualityHealthConcern": { + "supportedAirQualityHealthConcerns": { + "value": null + }, + "airQualityHealthConcern": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "otnDUID": { + "value": "U7CB2ZD4QPDUC", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-28T21:31:38.089Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "samsungce.silentAction": {}, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": 0, + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "airConditionerOdorControllerState": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 21, + "timestamp": "2025-01-28T21:31:35.935Z" + }, + "binaryId": { + "value": "ARA-WW-TP1-22-COMMON", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 6, + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "high", + "timestamp": "2025-02-09T14:07:45.816Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "samsungce.dustFilterAlarm": { + "alarmThreshold": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "supportedAlarmThresholds": { + "value": [180, 300, 500, 700], + "unit": "Hour", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "samsungce.airConditionerLighting": { + "supportedLightingLevels": { + "value": ["on", "off"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lighting": { + "value": "on", + "timestamp": "2025-02-09T09:30:03.213Z" + } + }, + "samsungce.buttonDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:41.282Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-02-09T16:38:17.028Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-02-09T05:17:39.792Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:39.792Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 16, + "maximum": 30, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-02-09T05:17:41.533Z" + }, + "coolingSetpoint": { + "value": 23, + "unit": "C", + "timestamp": "2025-02-09T14:07:45.643Z" + } + }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "alwaysOn": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "refresh": {}, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..181b62666c7 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json @@ -0,0 +1,600 @@ +{ + "components": { + "main": { + "doorControl": { + "door": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 30, + "timestamp": "2022-03-23T15:59:12.609Z" + }, + "defaultOvenMode": { + "value": "MicroWave", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP2X_DA-KS-MICROWAVE-0101X", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T00:11:12.010Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "di": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2023-07-03T22:00:58.832Z" + }, + "n": { + "value": "Samsung Microwave", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnmo": { + "value": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "vid": { + "value": "DA-KS-MICROWAVE-0101X", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "pi": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-03-23T15:59:12.742Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "modelCode": { + "value": "ME8000T-/AA0", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "microwave", + "timestamp": "2022-03-23T15:59:10.971Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "MicroWave", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "100%", + "supportedValues": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ] + } + } + }, + { + "mode": "ConvectionBake", + "supportedOptions": { + "temperature": { + "F": { + "min": 100, + "max": 425, + "default": 350, + "supportedValues": [ + 100, 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOptions": { + "temperature": { + "F": { + "min": 200, + "max": 425, + "default": 325, + "supportedValues": [ + 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Grill", + "supportedOptions": { + "temperature": { + "F": { + "min": 425, + "max": 425, + "default": 425, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SpeedBake", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "SpeedRoast", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "KeepWarm", + "supportedOptions": { + "temperature": { + "F": { + "min": 175, + "max": 175, + "default": 175, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Autocook", + "supportedOptions": {} + }, + { + "mode": "Cookie", + "supportedOptions": { + "temperature": { + "F": { + "min": 325, + "max": 325, + "default": 325, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOptions": { + "operationTime": { + "max": "00:06:30" + } + } + } + ] + }, + "timestamp": "2025-02-08T10:21:03.790Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["doorControl", "samsungce.hoodFanSpeed"], + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22120101, + "timestamp": "2023-07-03T09:36:13.282Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "621", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 1, + "unit": "F", + "timestamp": "2025-02-09T00:11:15.291Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T21:13:36.188Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Microwave", + "ConvectionBake", + "ConvectionRoast", + "grill", + "Others", + "warming" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "MicroWave", + "ConvectionBake", + "ConvectionRoast", + "Grill", + "SpeedBake", + "SpeedRoast", + "KeepWarm", + "Autocook", + "Cookie", + "SteamClean" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2025-02-09T00:01:09.108Z" + } + }, + "refresh": {}, + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-02-08T21:13:36.227Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ], + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "powerLevel": { + "value": "0%", + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.temperatures"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "1", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + "data": { + "href": "/temperatures/vs/0" + }, + "timestamp": "2023-07-19T05:50:12.609Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T21:13:36.357Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "U7CNQWBWSCD7C", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + } + } + }, + "hood": { + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "off", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "low", "high"], + "timestamp": "2025-02-08T21:13:36.289Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json new file mode 100644 index 00000000000..0c5a883b4f9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json @@ -0,0 +1,727 @@ +{ + "components": { + "pantry-01": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T13:55:01.720Z" + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2024-11-12T08:23:59.944Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode"], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 34, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 44, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 34, + "maximum": 44, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T14:48:16.247Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.freezerConvertMode"], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-23T04:42:18.178Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -8, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 5, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -8, + "maximum": 5, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 20, + "timestamp": "2024-11-08T01:09:17.382Z" + }, + "binaryId": { + "value": "TP2X_REF_20K", + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP2-21-COMMON_20220110", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "di": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "n": { + "value": "[refrigerator] Samsung", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmo": { + "value": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "vid": { + "value": "DA-REF-NORMAL-000001", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "pi": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "samsungce.dongleSoftwareInstallation", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.fridgeVacationMode", + "sec.diagnosticsInformation" + ], + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100101, + "timestamp": "2024-11-08T04:14:59.025Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-01-19T21:07:55.703Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-01-19T21:07:55.703Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker-02", + "pantry-01", + "pantry-02", + "cvroom", + "onedoor" + ], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-01-19T21:07:55.691Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": ["on", "off"], + "timestamp": "2025-01-19T21:07:55.799Z" + }, + "status": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.799Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1568087, + "deltaEnergy": 7, + "power": 6, + "powerEnergy": 13.555977778169844, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-09T17:38:01Z", + "end": "2025-02-09T17:49:00Z" + }, + "timestamp": "2025-02-09T17:49:00.507Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.rm.micomdata"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.rm.micomdata": "D0C0022B00000000000DFE15051F5AA54400000000000000000000000000000000000000000000000001F04A00C5E0", + "x.com.samsung.rm.micomdataLength": 94 + } + }, + "data": { + "href": "/rm/micomdata/vs/0" + }, + "timestamp": "2023-07-19T05:25:39.852Z" + } + }, + "refrigeration": { + "defrost": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.772Z" + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "drMaxDuration": { + "value": 1440, + "unit": "min", + "timestamp": "2022-02-07T11:39:47.504Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-02-07T11:39:47.504Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "otnDUID": { + "value": "P7CNQWBWM3XBW", + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": 1, + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": 100, + "timestamp": "2025-02-09T04:02:12.910Z" + }, + "waterFilterStatus": { + "value": "replace", + "timestamp": "2025-02-09T04:02:12.910Z" + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["temperatureMeasurement", "thermostatCoolingSetpoint"], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json new file mode 100644 index 00000000000..3bb2011a2b5 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json @@ -0,0 +1,274 @@ +{ + "components": { + "main": { + "custom.disabledComponents": { + "disabledComponents": { + "value": ["station"], + "timestamp": "2020-11-03T04:43:07.114Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2020-11-03T04:43:07.092Z" + } + }, + "refresh": {}, + "samsungce.robotCleanerOperatingState": { + "supportedOperatingState": { + "value": [ + "homing", + "error", + "idle", + "charging", + "chargingForRemainingJob", + "paused", + "cleaning" + ], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "operatingState": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + }, + "cleaningStep": { + "value": null + }, + "homingReason": { + "value": "none", + "timestamp": "2020-11-03T04:43:22.926Z" + }, + "isMapBasedOperationAvailable": { + "value": null + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2022-09-09T22:55:13.962Z" + }, + "type": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.alarms"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.code": "4", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.triggeredTime": "2023-06-18T15:59:30", + "x.com.samsung.da.state": "deleted" + } + ] + } + }, + "data": { + "href": "/alarms/vs/0" + }, + "timestamp": "2023-06-18T15:59:28.267Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2023-06-18T15:59:27.658Z" + } + }, + "robotCleanerTurboMode": { + "robotCleanerTurboMode": { + "value": "off", + "timestamp": "2022-09-08T02:53:49.826Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-02T23:30:52.793Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-03T13:34:18.508Z" + }, + "mnfv": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "di": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnsl": { + "value": null, + "timestamp": "2020-06-03T00:49:53.813Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-12-23T07:09:40.610Z" + }, + "n": { + "value": "[robot vacuum] Samsung", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmo": { + "value": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "timestamp": "2022-09-07T06:42:36.551Z" + }, + "vid": { + "value": "DA-RVC-NORMAL-000001", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnpv": { + "value": "00", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnos": { + "value": "Tizen(3/0)", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "pi": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + } + }, + "samsungce.robotCleanerCleaningMode": { + "supportedCleaningMode": { + "value": ["auto", "spot", "manual", "stop"], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "repeatModeEnabled": { + "value": false, + "timestamp": "2020-12-21T01:32:56.245Z" + }, + "supportRepeatMode": { + "value": true, + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "cleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "robotCleanerMovement": { + "robotCleanerMovement": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.robotCleanerMapAreaInfo", + "samsungce.robotCleanerMapCleaningInfo", + "samsungce.robotCleanerPatrol", + "samsungce.robotCleanerPetMonitoring", + "samsungce.robotCleanerPetMonitoringReport", + "samsungce.robotCleanerPetCleaningSchedule", + "soundDetection", + "samsungce.soundDetectionSensitivity", + "samsungce.musicPlaylist", + "mediaPlayback", + "mediaTrackControl", + "imageCapture", + "videoCapture", + "audioVolume", + "audioMute", + "audioNotification", + "powerConsumptionReport", + "custom.hepaFilter", + "samsungce.robotCleanerMotorFilter", + "samsungce.robotCleanerRelayCleaning", + "audioTrackAddressing", + "samsungce.robotCleanerWelcome" + ], + "timestamp": "2022-09-08T01:03:48.820Z" + } + }, + "robotCleanerCleaningMode": { + "robotCleanerCleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": null + }, + "newVersionAvailable": { + "value": null + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T09:26:07.107Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json new file mode 100644 index 00000000000..5535055f686 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json @@ -0,0 +1,786 @@ +{ + "components": { + "main": { + "samsungce.dishwasherWashingCourse": { + "customCourseCandidates": { + "value": null + }, + "washingCourse": { + "value": "normal", + "timestamp": "2025-02-08T20:21:26.497Z" + }, + "supportedCourses": { + "value": [ + "auto", + "normal", + "heavy", + "delicate", + "express", + "rinseOnly", + "selfClean" + ], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "dishwasherOperatingState": { + "completionTime": { + "value": "2025-02-08T22:49:26Z", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "progress": { + "value": null + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "dishwasherJobState": { + "value": "unknown", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.dishwasherWashingOptions": { + "dryPlus": { + "value": null + }, + "stormWash": { + "value": null + }, + "hotAirDry": { + "value": null + }, + "selectedZone": { + "value": { + "value": "all", + "settable": ["none", "upper", "lower", "all"] + }, + "timestamp": "2022-11-09T00:20:42.461Z" + }, + "speedBooster": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2023-11-24T14:46:55.375Z" + }, + "highTempWash": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-02-08T07:39:54.739Z" + }, + "sanitizingWash": { + "value": null + }, + "heatedDry": { + "value": null + }, + "zoneBooster": { + "value": { + "value": "none", + "settable": ["none", "left", "right", "all"] + }, + "timestamp": "2022-11-20T07:10:27.445Z" + }, + "addRinse": { + "value": null + }, + "supportedList": { + "value": [ + "selectedZone", + "zoneBooster", + "speedBooster", + "sanitize", + "highTempWash" + ], + "timestamp": "2021-06-27T01:19:38.000Z" + }, + "rinsePlus": { + "value": null + }, + "sanitize": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-01-18T23:49:09.964Z" + }, + "steamSoak": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_DW_A51_20_COMMON", + "timestamp": "2025-02-08T19:29:30.987Z" + } + }, + "custom.dishwasherOperatingProgress": { + "dishwasherOperatingProgress": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T20:21:26.386Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_DW_A51_20_COMMON_30230714", + "timestamp": "2023-11-02T15:58:55.699Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "di": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-07-04T13:53:32.032Z" + }, + "n": { + "value": "[dishwasher] Samsung", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmo": { + "value": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "vid": { + "value": "DA-WM-DW-000001", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "pi": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-06-27T01:19:37.615Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterConsumptionReport", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "sec.diagnosticsInformation", + "custom.waterFilter" + ], + "timestamp": "2025-02-08T19:29:32.447Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24040105, + "timestamp": "2024-07-02T02:56:22.508Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.dishwasherOperation": { + "supportedOperatingState": { + "value": ["ready", "running", "paused"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "reservable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "progressPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "remainingTimeStr": { + "value": "02:28", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": 148.0, + "unit": "min", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "timeLeftToStart": { + "value": 0.0, + "unit": "min", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "samsungce.dishwasherJobState": { + "scheduledJobs": { + "value": [ + { + "jobName": "washing", + "timeInSec": 3600 + }, + { + "jobName": "rinsing", + "timeInSec": 1020 + }, + { + "jobName": "drying", + "timeInSec": 1200 + } + ], + "timestamp": "2025-02-08T20:21:26.928Z" + }, + "dishwasherJobState": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:00:37.450Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 101600, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-08T20:21:21Z", + "end": "2025-02-08T20:21:26Z" + }, + "timestamp": "2025-02-08T20:21:26.596Z" + } + }, + "refresh": {}, + "samsungce.dishwasherWashingCourseDetails": { + "predefinedCourses": { + "value": [ + { + "courseName": "auto", + "energyUsage": 3, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 60, + "unit": "C" + }, + "expectedTime": { + "time": 136, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "normal", + "energyUsage": 3, + "waterUsage": 4, + "temperature": { + "min": 45, + "max": 62, + "unit": "C" + }, + "expectedTime": { + "time": 148, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "heavy", + "energyUsage": 4, + "waterUsage": 5, + "temperature": { + "min": 65, + "max": 65, + "unit": "C" + }, + "expectedTime": { + "time": 155, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "delicate", + "energyUsage": 2, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 50, + "unit": "C" + }, + "expectedTime": { + "time": 112, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "express", + "energyUsage": 2, + "waterUsage": 2, + "temperature": { + "min": 52, + "max": 52, + "unit": "C" + }, + "expectedTime": { + "time": 60, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "rinseOnly", + "energyUsage": 1, + "waterUsage": 1, + "temperature": { + "min": 40, + "max": 40, + "unit": "C" + }, + "expectedTime": { + "time": 14, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "selfClean", + "energyUsage": 5, + "waterUsage": 4, + "temperature": { + "min": 70, + "max": 70, + "unit": "C" + }, + "expectedTime": { + "time": 139, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "all"] + } + } + } + ], + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "waterUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "energyUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.operational.state"], + "if": ["oic.if.baseline", "oic.if.a"], + "currentMachineState": "idle", + "machineStates": ["pause", "active", "idle"], + "jobStates": [ + "None", + "Predrain", + "Prewash", + "Wash", + "Rinse", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:16:00", + "progressPercentage": "1" + } + }, + "data": { + "href": "/operational/state/0" + }, + "timestamp": "2023-07-19T04:23:15.606Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "custom.dishwasherOperatingPercentage": { + "dishwasherOperatingPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:00:37.555Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": null + }, + "supportedCourses": { + "value": ["82", "83", "84", "85", "86", "87", "88"], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "custom.dishwasherDelayStartTime": { + "dishwasherDelayStartTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2023-08-25T03:23:06.667Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2024-10-01T00:08:09.813Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "MTCNQWBWIV6TS", + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2022-07-20T03:37:30.706Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json new file mode 100644 index 00000000000..fe43b490387 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json @@ -0,0 +1,719 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedModes": { + "value": ["normal", "timeDry", "quickDry"], + "timestamp": "2025-02-08T18:10:10.497Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.840Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": "medium", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryingTemperature": { + "value": ["none", "extraLow", "low", "mediumLow", "medium", "high"], + "timestamp": "2025-01-04T22:52:14.884Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T06:49:02.183Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-08T18:10:10.990Z" + }, + "presets": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "3000000100111100020B000000000000", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-02-08T18:10:11.113Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.911Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "di": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "pi": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "normal", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "damp", "less", "normal", "more", "very"], + "timestamp": "2021-06-01T22:54:28.224Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": "on", + "timestamp": "2025-02-08T18:10:11.986Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "01", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "9C", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "9E", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8308", + "default": "mediumLow", + "options": ["mediumLow"] + } + } + }, + { + "cycle": "9B", + "supportedOptions": { + "dryingLevel": { + "raw": "D520", + "default": "very", + "options": ["very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "27", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "E5", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A0", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A4", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "853E", + "default": "high", + "options": ["extraLow", "low", "mediumLow", "medium", "high"] + } + } + }, + { + "cycle": "A6", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A3", + "supportedOptions": { + "dryingLevel": { + "raw": "D308", + "default": "normal", + "options": ["normal"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "A2", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8102", + "default": "extraLow", + "options": ["extraLow"] + } + } + } + ], + "timestamp": "2025-01-04T22:52:14.884Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-05T16:04:06.674Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:59:11.115Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:10:10.825Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4495500, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T04:00:19Z", + "end": "2025-02-08T18:10:11Z" + }, + "timestamp": "2025-02-08T18:10:11.053Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-02-08T19:25:10Z", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:54:28.372Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "x.com.samsung.da.serialNum": "FFFFFFFFFFFFFFF", + "x.com.samsung.da.otnDUID": "7XCDM6YAIRCGM", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20112625", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T22:48:43.192Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:10:10.970Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCourses": { + "value": [ + "01", + "9C", + "A5", + "9E", + "9B", + "27", + "E5", + "A0", + "A4", + "A6", + "A3", + "A2" + ], + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T06:49:02.183Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T06:49:02.721Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.dryerOperatingState": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T13:43:26.961Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 57 + }, + { + "jobName": "cooling", + "timeInMin": 3 + } + ], + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTimeStr": { + "value": "01:15", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTime": { + "value": 75, + "unit": "min", + "timestamp": "2025-02-07T04:00:18.186Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "7XCDM6YAIRCGM", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "20", "30", "40", "50", "60"], + "timestamp": "2021-06-01T22:54:28.224Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-02-08T18:10:10.840Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json new file mode 100644 index 00000000000..6a141c9462e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json @@ -0,0 +1,1243 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedModes": { + "value": ["normal", "quickWash"], + "timestamp": "2025-02-07T02:29:55.152Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-07T02:29:55.546Z" + }, + "minimumReservableTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null + }, + "waterLevel": { + "value": null + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "tapCold", "cold", "warm", "hot", "extraHot"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerWaterTemperature": { + "value": "warm", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": null + }, + "regularSoftenerAlarmEnabled": { + "value": null + }, + "regularSoftenerInitialAmount": { + "value": null + }, + "regularSoftenerRemainingAmount": { + "value": null + }, + "regularSoftenerDosage": { + "value": null + }, + "regularSoftenerOrderThreshold": { + "value": null + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-15T14:11:34.909Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "2001000100131100022B010000000000", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "description": { + "value": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_TP2_20_COMMON", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null + }, + "supportedWaterValve": { + "value": null + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-02-07T03:54:45Z", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-07T02:29:55.546Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-07T03:09:45.456Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "01", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43B", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "833E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "70", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "hot", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "55", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "71", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A20F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "72", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "77", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A21F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium", "high"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "E5", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "57", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A520", + "default": "extraHigh", + "options": ["extraHigh"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "73", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "74", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A207", + "default": "low", + "options": ["rinseHold", "noSpin", "low"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "75", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "medium", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "810E", + "default": "tapCold", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "78", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C13E", + "default": "extraLight", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + } + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-06-01T22:52:20.068Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_TP2_20_COMMON_30230804", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "di": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmo": { + "value": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "pi": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": null + }, + "supportedDryerDryLevel": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.autoDispenseDetergent", + "samsungce.autoDispenseSoftener", + "samsungce.waterConsumptionReport", + "samsungce.washerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "samsungce.energyPlanner", + "demandResponseLoadControl", + "samsungce.softenerAutoReplenishment", + "samsungce.softenerOrder", + "samsungce.softenerState", + "samsungce.washerBubbleSoak", + "samsungce.washerFreezePrevent", + "custom.dryerDryLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener" + ], + "timestamp": "2024-07-01T16:13:35.173Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:14:52.963Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "210", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-04T14:21:57.546Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 23 + }, + { + "jobName": "rinse", + "timeInMin": 10 + }, + { + "jobName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 23 + }, + { + "phaseName": "rinse", + "timeInMin": 10 + }, + { + "phaseName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "remainingTimeStr": { + "value": "00:45", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobPhase": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operationTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + }, + "remainingTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.534Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-07T02:29:55.407Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 352800, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T03:09:24Z", + "end": "2025-02-07T03:09:45Z" + }, + "timestamp": "2025-02-07T03:09:45.703Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentAlarmEnabled": { + "value": null + }, + "neutralDetergentOrderThreshold": { + "value": null + }, + "babyDetergentInitialAmount": { + "value": null + }, + "babyDetergentType": { + "value": null + }, + "neutralDetergentInitialAmount": { + "value": null + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null + }, + "orderThreshold": { + "value": null + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": [ + "none", + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerSoilLevel": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": null + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-07T02:29:55.805Z" + }, + "presets": { + "value": null + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:52:19.999Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "x.com.samsung.da.serialNum": "01FW57AR401623N", + "x.com.samsung.da.otnDUID": "U7CNQWBWJM5U4", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "210", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02674A220725(F541)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20050607", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T16:52:15.994Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null + }, + "dosage": { + "value": null + }, + "softenerType": { + "value": null + }, + "initialAmount": { + "value": null + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-07T02:29:55.634Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedCourses": { + "value": [ + "01", + "70", + "55", + "71", + "72", + "77", + "E5", + "57", + "73", + "74", + "75", + "78" + ], + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null + }, + "washingTime": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-15T14:11:34.909Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-15T14:26:38.584Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2022-06-15T14:11:37.255Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-06-15T14:11:37.255Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "otnDUID": { + "value": "U7CNQWBWJM5U4", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T23:36:22.798Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "high", + "timestamp": "2025-02-07T02:29:55.691Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json new file mode 100644 index 00000000000..e9d8addfcb3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json @@ -0,0 +1,51 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "not present", + "timestamp": "2025-02-11T13:58:50.044Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.471Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T14:23:22.053Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:36:16.823Z" + } + }, + "refresh": {}, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-11T13:58:50.044Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json new file mode 100644 index 00000000000..dd4b8717195 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json @@ -0,0 +1,98 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 32, + "unit": "%", + "timestamp": "2025-02-11T14:36:17.275Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.448Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:23:21.556Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "auto", + "data": { + "supportedThermostatFanModes": ["on", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatFanModes": { + "value": ["on", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "cool", "auxheatonly", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatModes": { + "value": ["off", "cool", "auxheatonly", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 73, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/fake_fan.json b/tests/components/smartthings/fixtures/device_status/fake_fan.json new file mode 100644 index 00000000000..91efb69cee6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/fake_fan.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 60, + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..bff74f135be --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json @@ -0,0 +1,23 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 39, + "unit": "%", + "timestamp": "2025-02-07T02:39:25.819Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..6bdf7ceb2dd --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json @@ -0,0 +1,75 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.671Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.823Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..5868472267c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json @@ -0,0 +1,94 @@ +{ + "components": { + "main": { + "colorControl": { + "saturation": { + "value": 60, + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "color": { + "value": null + }, + "hue": { + "value": 60.8072, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.678Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "samsungim.hueSyncMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T07:08:19.519Z" + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-06T15:14:52.807Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/iphone.json b/tests/components/smartthings/fixtures/device_status/iphone.json new file mode 100644 index 00000000000..618ce440ff0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/iphone.json @@ -0,0 +1,12 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "present", + "timestamp": "2023-09-22T18:12:25.012Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json new file mode 100644 index 00000000000..e0b37de7e3c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json @@ -0,0 +1,79 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-08T14:00:28.332Z" + } + }, + "threeAxis": { + "threeAxis": { + "value": [20, 8, -1042], + "unit": "mG", + "timestamp": "2025-02-09T17:27:36.673Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 67.0, + "unit": "F", + "timestamp": "2025-02-09T17:56:19.744Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 50, + "unit": "%", + "timestamp": "2025-02-09T12:24:02.074Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T04:20:25.601Z" + }, + "currentVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.593Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "accelerationSensor": { + "acceleration": { + "value": "inactive", + "timestamp": "2025-02-09T17:27:46.812Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..b4263e7eb87 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json @@ -0,0 +1,57 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-12-04T10:10:02.934Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "refresh": {}, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20, + "unit": "C", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T10:09:47.758Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/smart_plug.json b/tests/components/smartthings/fixtures/device_status/smart_plug.json new file mode 100644 index 00000000000..f4f591483c6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/smart_plug.json @@ -0,0 +1,43 @@ +{ + "components": { + "main": { + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-08T19:37:03.622Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "currentVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.594Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:31:12.210Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sonos_player.json b/tests/components/smartthings/fixtures/device_status/sonos_player.json new file mode 100644 index 00000000000..057b6c62d0d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sonos_player.json @@ -0,0 +1,259 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-02T13:18:40.078Z" + }, + "playbackStatus": { + "value": "playing", + "timestamp": "2025-02-09T19:53:58.330Z" + } + }, + "mediaPresets": { + "presets": { + "value": [ + { + "id": "10", + "imageUrl": "https://www.storytel.com//images/320x320/0000059036.jpg", + "mediaSource": "Storytel", + "name": "Dra \u00e5t skogen Sune!" + }, + { + "id": "22", + "imageUrl": "https://www.storytel.com//images/320x320/0000001894.jpg", + "mediaSource": "Storytel", + "name": "Fy katten Sune" + }, + { + "id": "29", + "imageUrl": "https://www.storytel.com//images/320x320/0000001896.jpg", + "mediaSource": "Storytel", + "name": "Gult \u00e4r fult, Sune" + }, + { + "id": "2", + "imageUrl": "https://static.mytuner.mobi/media/tvos_radios/2l5zg6lhjbab.png", + "mediaSource": "myTuner Radio", + "name": "Kiss" + }, + { + "id": "3", + "imageUrl": "https://www.storytel.com//images/320x320/0000046017.jpg", + "mediaSource": "Storytel", + "name": "L\u00e4skigt Sune!" + }, + { + "id": "16", + "imageUrl": "https://www.storytel.com//images/320x320/0002590598.jpg", + "mediaSource": "Storytel", + "name": "Pluggh\u00e4sten Sune" + }, + { + "id": "14", + "imageUrl": "https://www.storytel.com//images/320x320/0000000070.jpg", + "mediaSource": "Storytel", + "name": "Sagan om Sune" + }, + { + "id": "18", + "imageUrl": "https://www.storytel.com//images/320x320/0000006452.jpg", + "mediaSource": "Storytel", + "name": "Sk\u00e4mtaren Sune" + }, + { + "id": "26", + "imageUrl": "https://www.storytel.com//images/320x320/0000001892.jpg", + "mediaSource": "Storytel", + "name": "Spik och panik, Sune!" + }, + { + "id": "7", + "imageUrl": "https://www.storytel.com//images/320x320/0003119145.jpg", + "mediaSource": "Storytel", + "name": "Sune - T\u00e5gsemestern" + }, + { + "id": "25", + "imageUrl": "https://www.storytel.com//images/320x320/0000000071.jpg", + "mediaSource": "Storytel", + "name": "Sune b\u00f6rjar tv\u00e5an" + }, + { + "id": "9", + "imageUrl": "https://www.storytel.com//images/320x320/0000006448.jpg", + "mediaSource": "Storytel", + "name": "Sune i Grekland" + }, + { + "id": "8", + "imageUrl": "https://www.storytel.com//images/320x320/0002492498.jpg", + "mediaSource": "Storytel", + "name": "Sune i Ullared" + }, + { + "id": "30", + "imageUrl": "https://www.storytel.com//images/320x320/0002072946.jpg", + "mediaSource": "Storytel", + "name": "Sune och familjen Anderssons sjuka jul" + }, + { + "id": "17", + "imageUrl": "https://www.storytel.com//images/320x320/0000000475.jpg", + "mediaSource": "Storytel", + "name": "Sune och klantpappan" + }, + { + "id": "11", + "imageUrl": "https://www.storytel.com//images/320x320/0000042688.jpg", + "mediaSource": "Storytel", + "name": "Sune och Mamma Mysko" + }, + { + "id": "20", + "imageUrl": "https://www.storytel.com//images/320x320/0000000072.jpg", + "mediaSource": "Storytel", + "name": "Sune och syster vampyr" + }, + { + "id": "15", + "imageUrl": "https://www.storytel.com//images/320x320/0000039918.jpg", + "mediaSource": "Storytel", + "name": "Sune slutar f\u00f6rsta klass" + }, + { + "id": "5", + "imageUrl": "https://www.storytel.com//images/320x320/0000017431.jpg", + "mediaSource": "Storytel", + "name": "Sune v\u00e4rsta killen!" + }, + { + "id": "27", + "imageUrl": "https://www.storytel.com//images/320x320/0000068900.jpg", + "mediaSource": "Storytel", + "name": "Sunes halloween" + }, + { + "id": "19", + "imageUrl": "https://www.storytel.com//images/320x320/0000000476.jpg", + "mediaSource": "Storytel", + "name": "Sunes hemligheter" + }, + { + "id": "21", + "imageUrl": "https://www.storytel.com//images/320x320/0002370989.jpg", + "mediaSource": "Storytel", + "name": "Sunes hj\u00e4rnsl\u00e4pp" + }, + { + "id": "24", + "imageUrl": "https://www.storytel.com//images/320x320/0000001889.jpg", + "mediaSource": "Storytel", + "name": "Sunes jul" + }, + { + "id": "28", + "imageUrl": "https://www.storytel.com//images/320x320/0000034437.jpg", + "mediaSource": "Storytel", + "name": "Sunes party" + }, + { + "id": "4", + "imageUrl": "https://www.storytel.com//images/320x320/0000006450.jpg", + "mediaSource": "Storytel", + "name": "Sunes skolresa" + }, + { + "id": "13", + "imageUrl": "https://www.storytel.com//images/320x320/0000000477.jpg", + "mediaSource": "Storytel", + "name": "Sunes sommar" + }, + { + "id": "12", + "imageUrl": "https://www.storytel.com//images/320x320/0000046015.jpg", + "mediaSource": "Storytel", + "name": "Sunes Sommarstuga" + }, + { + "id": "6", + "imageUrl": "https://www.storytel.com//images/320x320/0002099327.jpg", + "mediaSource": "Storytel", + "name": "Supersnuten Sune" + }, + { + "id": "23", + "imageUrl": "https://www.storytel.com//images/320x320/0000563738.jpg", + "mediaSource": "Storytel", + "name": "Zunes stolpskott" + } + ], + "timestamp": "2025-02-02T13:18:48.272Z" + } + }, + "audioVolume": { + "volume": { + "value": 15, + "unit": "%", + "timestamp": "2025-02-09T19:57:37.230Z" + } + }, + "mediaGroup": { + "groupMute": { + "value": "unmuted", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupPrimaryDeviceId": { + "value": "RINCON_38420B9108F601400", + "timestamp": "2025-02-09T19:52:24.000Z" + }, + "groupId": { + "value": "RINCON_38420B9108F601400:3579458382", + "timestamp": "2025-02-09T19:54:06.936Z" + }, + "groupVolume": { + "value": 12, + "unit": "%", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupRole": { + "value": "ungrouped", + "timestamp": "2025-02-09T19:52:23.974Z" + } + }, + "refresh": {}, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": ["nextTrack", "previousTrack"], + "timestamp": "2025-02-02T13:18:40.123Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T19:57:35.487Z" + } + }, + "audioNotification": {}, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": { + "album": "Forever Young", + "albumArtUrl": "http://192.168.1.123:1400/getaa?s=1&u=x-sonos-spotify%3aspotify%253atrack%253a3bg2qahpZmsg5wV2EMPXIk%3fsid%3d9%26flags%3d8232%26sn%3d9", + "artist": "David Guetta", + "mediaSource": "Spotify", + "title": "Forever Young" + }, + "timestamp": "2025-02-09T19:53:55.615Z" + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json new file mode 100644 index 00000000000..a0bcbd742f4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json @@ -0,0 +1,164 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-09T15:42:12.923Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-02-09T15:42:12.923Z" + } + }, + "samsungvd.soundFrom": { + "mode": { + "value": 3, + "timestamp": "2025-02-09T15:42:13.215Z" + }, + "detailName": { + "value": "External Device", + "timestamp": "2025-02-09T15:42:13.215Z" + } + }, + "audioVolume": { + "volume": { + "value": 17, + "unit": "%", + "timestamp": "2025-02-09T17:25:51.839Z" + } + }, + "samsungvd.audioGroupInfo": { + "role": { + "value": null + }, + "status": { + "value": null + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["digital", "HDMI1", "bluetooth", "wifi", "HDMI2"], + "timestamp": "2025-02-09T17:18:44.680Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2025-02-09T17:18:44.680Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:25:51.536Z" + } + }, + "ocf": { + "st": { + "value": "2024-12-10T02:12:44Z", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mndt": { + "value": "2023-01-01", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnfv": { + "value": "SAT-iMX8M23WWC-1010.5", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnhw": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "di": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnsl": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "n": { + "value": "Soundbar Living", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmo": { + "value": "HW-Q990C", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "vid": { + "value": "VD-NetworkAudio-002S", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnml": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnpv": { + "value": "7.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "pi": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T17:18:44.787Z" + } + }, + "samsungvd.thingStatus": { + "updatedTime": { + "value": 1739115734, + "timestamp": "2025-02-09T15:42:13.949Z" + }, + "status": { + "value": "Idle", + "timestamp": "2025-02-09T15:42:13.949Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "audioTrackData": { + "value": { + "title": "", + "artist": "", + "album": "" + }, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "elapsedTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.828Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json new file mode 100644 index 00000000000..18496942e2f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json @@ -0,0 +1,266 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop", "fastForward", "rewind"], + "timestamp": "2020-05-07T02:58:10.250Z" + }, + "playbackStatus": { + "value": null, + "timestamp": "2020-08-04T21:53:22.108Z" + } + }, + "audioVolume": { + "volume": { + "value": 13, + "unit": "%", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "samsungvd.supportsPowerOnByOcf": { + "supportsPowerOnByOcf": { + "value": null, + "timestamp": "2020-10-29T10:47:20.305Z" + } + }, + "samsungvd.mediaInputSource": { + "supportedInputSourcesMap": { + "value": [ + { + "id": "dtv", + "name": "TV" + }, + { + "id": "HDMI1", + "name": "PlayStation 4" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + } + ], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": ["digitalTv", "HDMI1", "HDMI4", "HDMI4"], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "custom.tvsearch": {}, + "samsungvd.ambient": {}, + "refresh": {}, + "custom.error": { + "error": { + "value": null, + "timestamp": "2020-08-04T21:53:22.148Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.tv.deviceinfo"], + "if": ["oic.if.baseline", "oic.if.r"], + "x.com.samsung.country": "USA", + "x.com.samsung.infolinkversion": "T-INFOLINK2017-1008", + "x.com.samsung.modelid": "17_KANTM_UHD", + "x.com.samsung.tv.blemac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.btmac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.category": "tv", + "x.com.samsung.tv.countrycode": "US", + "x.com.samsung.tv.duid": "B2NBQRAG357IX", + "x.com.samsung.tv.ethmac": "c0:48:e6:e7:fc:2c", + "x.com.samsung.tv.p2pmac": "ce:6e:a4:1f:4c:f6", + "x.com.samsung.tv.udn": "717fb7ed-b310-4cfe-8954-1cd8211dd689", + "x.com.samsung.tv.wifimac": "cc:6e:a4:1f:4c:f6" + } + }, + "data": { + "href": "/sec/tv/deviceinfo" + }, + "timestamp": "2021-08-30T19:18:12.303Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2021-10-16T15:18:11.317Z" + } + }, + "tvChannel": { + "tvChannel": { + "value": "", + "timestamp": "2020-05-07T02:58:10.479Z" + }, + "tvChannelName": { + "value": "", + "timestamp": "2021-08-21T18:53:06.643Z" + } + }, + "ocf": { + "st": { + "value": "2021-08-21T14:50:34Z", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mndt": { + "value": "2017-01-01", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mnfv": { + "value": "T-KTMAKUC-1290.3", + "timestamp": "2021-08-21T18:52:57.543Z" + }, + "mnhw": { + "value": "0-0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "di": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnsl": { + "value": "http://www.samsung.com/sec/tv/overview/", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + }, + "n": { + "value": "[TV] Samsung 8 Series (49)", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmo": { + "value": "UN49MU8000", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "vid": { + "value": "VD-STV_2017_K", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnpv": { + "value": "Tizen 3.0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnos": { + "value": "4.1.10", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "pi": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + } + }, + "custom.picturemode": { + "pictureMode": { + "value": "Dynamic", + "timestamp": "2020-12-23T01:33:37.069Z" + }, + "supportedPictureModes": { + "value": ["Dynamic", "Standard", "Natural", "Movie"], + "timestamp": "2020-05-07T02:58:10.585Z" + }, + "supportedPictureModesMap": { + "value": [ + { + "id": "modeDynamic", + "name": "Dynamic" + }, + { + "id": "modeStandard", + "name": "Standard" + }, + { + "id": "modeNatural", + "name": "Natural" + }, + { + "id": "modeMovie", + "name": "Movie" + } + ], + "timestamp": "2020-12-23T01:33:37.069Z" + } + }, + "samsungvd.ambientContent": { + "supportedAmbientApps": { + "value": [], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.accessibility": {}, + "custom.recording": {}, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungvd.ambient", "samsungvd.ambientContent"], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.soundmode": { + "supportedSoundModesMap": { + "value": [ + { + "id": "modeStandard", + "name": "Standard" + } + ], + "timestamp": "2021-08-21T19:19:52.887Z" + }, + "soundMode": { + "value": "Standard", + "timestamp": "2020-12-23T01:33:37.272Z" + }, + "supportedSoundModes": { + "value": ["Standard"], + "timestamp": "2021-08-21T19:19:52.887Z" + } + }, + "audioMute": { + "mute": { + "value": "muted", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null, + "timestamp": "2020-08-04T21:53:22.384Z" + } + }, + "custom.launchapp": {}, + "samsungvd.firmwareVersion": { + "firmwareVersion": { + "value": null, + "timestamp": "2020-10-29T10:47:19.376Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json new file mode 100644 index 00000000000..c2c36fa249e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json @@ -0,0 +1,97 @@ +{ + "components": { + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "pending cool", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 814.7469111058201, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "heatingSetpointRange": { + "value": { + "maximum": 3226.693210895862, + "step": 9234.459191378826, + "minimum": 6214.940743832475 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "maximum": 1826.722761785079, + "step": 138.2080712609211, + "minimum": 9268.726934158902 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "temperature": { + "value": 8554.194688973037, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "followschedule", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatFanModes": { + "value": ["on"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "auxheatonly", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatModes": { + "value": ["rush hour"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "battery": { + "quantity": { + "value": 51, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "type": { + "value": "38140", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "maximum": 7288.145606306409, + "step": 7620.031701049315, + "minimum": 4997.721228739137 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "coolingSetpoint": { + "value": 244.33726326608746, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_valve.json b/tests/components/smartthings/fixtures/device_status/virtual_valve.json new file mode 100644 index 00000000000..8cb66c72595 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_valve.json @@ -0,0 +1,13 @@ +{ + "components": { + "main": { + "refresh": {}, + "valve": { + "valve": { + "value": "closed", + "timestamp": "2025-02-11T11:27:02.262Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json new file mode 100644 index 00000000000..8200bfe81a1 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json @@ -0,0 +1,28 @@ +{ + "components": { + "main": { + "waterSensor": { + "water": { + "value": "dry", + "timestamp": "2025-02-10T21:58:18.784Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": 84, + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "type": { + "value": "46120", + "timestamp": "2025-02-10T21:58:18.784Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..0bb1af96f70 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json @@ -0,0 +1,110 @@ +{ + "components": { + "main": { + "lock": { + "supportedUnlockDirections": { + "value": null + }, + "supportedLockValues": { + "value": null + }, + "lock": { + "value": "locked", + "data": {}, + "timestamp": "2025-02-09T17:29:56.641Z" + }, + "supportedLockCommands": { + "value": null + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 86, + "unit": "%", + "timestamp": "2025-02-09T17:18:14.150Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T11:48:45.332Z" + }, + "currentVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.328Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "lockCodes": { + "codeLength": { + "value": null, + "timestamp": "2020-08-04T15:29:24.127Z" + }, + "maxCodes": { + "value": 250, + "timestamp": "2023-08-22T01:34:19.751Z" + }, + "maxCodeLength": { + "value": 8, + "timestamp": "2023-08-22T01:34:18.690Z" + }, + "codeChanged": { + "value": "8 unset", + "data": { + "codeName": "Code 8" + }, + "timestamp": "2025-01-06T04:56:31.712Z" + }, + "lock": { + "value": "locked", + "data": { + "method": "manual" + }, + "timestamp": "2023-07-10T23:03:42.305Z" + }, + "minCodeLength": { + "value": 4, + "timestamp": "2023-08-22T01:34:18.781Z" + }, + "codeReport": { + "value": 5, + "timestamp": "2022-08-01T01:36:58.424Z" + }, + "scanCodes": { + "value": "Complete", + "timestamp": "2025-01-06T04:56:31.730Z" + }, + "lockCodes": { + "value": "{\"1\":\"Salim\",\"2\":\"Saima\",\"3\":\"Sarah\",\"4\":\"Aisha\",\"5\":\"Moiz\"}", + "timestamp": "2025-01-06T04:56:28.325Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..5ef0e2fd9eb --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,70 @@ +{ + "items": [ + { + "deviceId": "f0af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "aeotec-home-energy-meter-gen5", + "label": "Aeotec Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "3e0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6911ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "93257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "components": [ + { + "id": "main", + "label": "Meter", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "voltageMeasurement", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "parentDeviceId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", + "profile": { + "id": "6372c227-93c7-32ef-9be5-aef2221adff1" + }, + "zwave": { + "networkId": "0A", + "driverId": "b98b34ce-1d1d-480c-bb17-41307a90cde0", + "executingLocally": true, + "hubId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", + "networkSecurityLevel": "ZWAVE_S0_LEGACY", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 95 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json new file mode 100644 index 00000000000..9e0c130978c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -0,0 +1,62 @@ +{ + "items": [ + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "name": "base-electric-meter", + "label": "Aeon Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8e619cd9-c271-3ba0-9015-62bc074bc47f", + "deviceManufacturerCode": "0086-0002-0009", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-06-03T16:23:57.284Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "d382796f-8ed5-3088-8735-eb03e962203b" + }, + "zwave": { + "networkId": "2A", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 9 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..a9e3bddb2ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + "name": "c2c-arlo-pro-3-switch", + "label": "2nd Floor Hallway", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c_arlo_pro_3", + "deviceManufacturerCode": "Arlo", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "soundSensor", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "videoStream", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "videoCapture", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "alarm", + "version": 1 + } + ], + "categories": [ + { + "name": "Camera", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-21T21:55:59.340Z", + "profile": { + "id": "89aefc3a-e210-4678-944c-638d47d296f6" + }, + "viper": { + "manufacturerName": "Arlo", + "modelName": "VMC4041PB", + "endpointAppId": "viper_555d6f40-b65a-11ea-8fe0-77cb99571462" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_shade.json b/tests/components/smartthings/fixtures/devices/c2c_shade.json new file mode 100644 index 00000000000..265eab11ff5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_shade.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "571af102-15db-4030-b76b-245a691f74a5", + "name": "c2c-shade", + "label": "Curtain 1A", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c-shade", + "deviceManufacturerCode": "WonderLabs Company", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-07T23:01:15.883Z", + "profile": { + "id": "0ceffb3e-10d3-4123-bb42-2a92c93c6e25" + }, + "viper": { + "manufacturerName": "WonderLabs Company", + "modelName": "WoCurtain3", + "hwVersion": "WoCurtain3-WoCurtain3", + "endpointAppId": "viper_f18eb770-077d-11ea-bb72-9922e3ed0d38" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json new file mode 100644 index 00000000000..68cdbdf4499 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -0,0 +1,67 @@ +{ + "items": [ + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "name": "plug-level-power", + "label": "Dimmer Debian", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "bb7c4cfb-6eaf-3efc-823b-06a54fc9ded9", + "deviceManufacturerCode": "CentraLite", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-08-15T22:16:37.926Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" + }, + "zigbee": { + "eui": "000D6F0003C04BC9", + "networkId": "F50E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json new file mode 100644 index 00000000000..a5de2e2cbfe --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -0,0 +1,71 @@ +{ + "items": [ + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "name": "contact-profile", + "label": ".Front Door Open/Closed Sensor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "a7f2c1d9-89b3-35a4-b217-fc68d9e4e752", + "deviceManufacturerCode": "Visonic", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "ContactSensor", + "categoryType": "manufacturer" + }, + { + "name": "ContactSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2023-09-28T17:38:59.179Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" + }, + "zigbee": { + "eui": "000D6F000576F604", + "networkId": "5A44", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json new file mode 100644 index 00000000000..ec7f16b090a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -0,0 +1,311 @@ +{ + "items": [ + { + "deviceId": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "name": "[room a/c] Samsung", + "label": "AC Office Granit", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", + "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", + "roomId": "85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "1", + "label": "1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-04-06T16:43:34.753Z", + "profile": { + "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "[room a/c] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "platformVersion": "0G3MPDCKA00010E", + "platformOS": "TizenRT2.0", + "hwVersion": "1.0", + "firmwareVersion": "0.1.0", + "vendorId": "DA-AC-RAC-000001", + "lastSignupTime": "2021-04-06T16:43:27.889445Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json new file mode 100644 index 00000000000..8d9ebde5bcd --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -0,0 +1,264 @@ +{ + "items": [ + { + "deviceId": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "name": "Samsung-Room-Air-Conditioner", + "label": "Aire Dormitorio Principal", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "1f66199a-1773-4d8f-97b7-44c312a62cf7", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "bypassable", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, + { + "id": "samsungce.airConditionerBeep", + "version": 1 + }, + { + "id": "samsungce.airConditionerLighting", + "version": 1 + }, + { + "id": "samsungce.airQualityHealthConcern", + "version": 1 + }, + { + "id": "samsungce.buttonDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dustFilterAlarm", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.silentAction", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.welcomeCooling", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-28T21:31:35.755Z", + "profile": { + "id": "091a55f4-7054-39fa-b23e-b56deb7580f8" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung-Room-Air-Conditioner", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "ARA-WW-TP1-22-COMMON_11240702", + "vendorId": "DA-AC-RAC-01001", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2025-01-28T21:31:30.090416369Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..f6599fee461 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -0,0 +1,176 @@ +{ + "items": [ + { + "deviceId": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "name": "Samsung Microwave", + "label": "Microwave", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-MICROWAVE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "deviceTypeName": "oic.d.microwave", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "doorControl", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Microwave", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hood", + "label": "hood", + "capabilities": [ + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-03-23T15:59:10.704Z", + "profile": { + "id": "e5db3b6f-cad6-3caa-9775-9c9cae20f4a4" + }, + "ocf": { + "ocfDeviceType": "oic.d.microwave", + "name": "Samsung Microwave", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "vendorId": "DA-KS-MICROWAVE-0101X", + "vendorResourceClientServerVersion": "MediaTek Release 2.220916.2", + "lastSignupTime": "2022-04-17T15:33:11.063457Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json new file mode 100644 index 00000000000..67afc0ad32c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -0,0 +1,412 @@ +{ + "items": [ + { + "deviceId": "7db87911-7dce-1cf2-7119-b953432a2f09", + "name": "[refrigerator] Samsung", + "label": "Refrigerator", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "3a1f7e7c-4e59-4c29-adb0-0813be691efd", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + }, + { + "name": "Refrigerator", + "categoryType": "user" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-01-08T16:50:43.544Z", + "profile": { + "id": "f2a9af35-5df8-3477-91df-94941d302591" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "[refrigerator] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "A-RFWW-TP2-21-COMMON_20220110", + "vendorId": "DA-REF-NORMAL-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.210524.1", + "lastSignupTime": "2024-08-06T15:24:29.362093Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json new file mode 100644 index 00000000000..b355eedb17a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -0,0 +1,119 @@ +{ + "items": [ + { + "deviceId": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "name": "[robot vacuum] Samsung", + "label": "Robot vacuum", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-RVC-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "5d425f41-042a-4d9a-92c4-e43150a61bae", + "deviceTypeName": "Samsung OCF Robot Vacuum", + "components": [ + { + "id": "main", + "label": "Robot vacuum", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "robotCleanerTurboMode", + "version": 1 + }, + { + "id": "robotCleanerMovement", + "version": 1 + }, + { + "id": "robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerOperatingState", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "RobotCleaner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-06-06T23:04:25Z", + "profile": { + "id": "61b1c3cd-61cc-3dde-a4ba-9477d5e559cb" + }, + "ocf": { + "ocfDeviceType": "oic.d.robotcleaner", + "name": "[robot vacuum] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "platformVersion": "00", + "platformOS": "Tizen(3/0)", + "hwVersion": "1.0", + "firmwareVersion": "1.0", + "vendorId": "DA-RVC-NORMAL-000001", + "lastSignupTime": "2020-11-03T04:43:02.729Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json new file mode 100644 index 00000000000..1c7024e153f --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -0,0 +1,168 @@ +{ + "items": [ + { + "deviceId": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "name": "[dishwasher] Samsung", + "label": "Dishwasher", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-DW-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "deviceTypeName": "Samsung OCF Dishwasher", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "dishwasherOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingProgress", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingPercentage", + "version": 1 + }, + { + "id": "custom.dishwasherDelayStartTime", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dishwasherJobState", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourse", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourseDetails", + "version": 1 + }, + { + "id": "samsungce.dishwasherOperation", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingOptions", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dishwasher", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-27T01:19:35.408Z", + "profile": { + "id": "0cba797c-40ee-3473-aa01-4ee5b6cb8c67" + }, + "ocf": { + "ocfDeviceType": "oic.d.dishwasher", + "name": "[dishwasher] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_DW_A51_20_COMMON_30230714", + "vendorId": "DA-WM-DW-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-10-16T17:28:59.984202Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json new file mode 100644 index 00000000000..b9a650718e2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -0,0 +1,204 @@ +{ + "items": [ + { + "deviceId": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "name": "[dryer] Samsung", + "label": "Dryer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.dryerCycle", + "version": 1 + }, + { + "id": "samsungce.dryerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.dryerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTemperature", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.dryerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.dryerOperatingState", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dryer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.dryerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:54:25.907Z", + "profile": { + "id": "53a1d049-eeda-396c-8324-e33438ef57be" + }, + "ocf": { + "ocfDeviceType": "oic.d.dryer", + "name": "[dryer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WD-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-06-01T22:54:22.826697Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json new file mode 100644 index 00000000000..852a2afa932 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -0,0 +1,260 @@ +{ + "items": [ + { + "deviceId": "f984b91d-f250-9d42-3436-33f09a422a47", + "name": "[washer] Samsung", + "label": "Washer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:52:18.023Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "DA_WM_TP2_20_COMMON_30230804", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.211214.1", + "lastSignupTime": "2021-06-01T22:52:13.923649Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_sensor.json b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json new file mode 100644 index 00000000000..4c37a17f1a0 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "d5dc3299-c266-41c7-bd08-f540aea54b89", + "name": "ecobee Sensor", + "label": "Child Bedroom", + "manufacturerName": "0A0b", + "presentationId": "ST_635a866e-a3ea-4184-9d60-9c72ea603dfd", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "presenceSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "MotionSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.283Z", + "profile": { + "id": "8ab3ca07-0d07-471b-a276-065e46d7aa8a" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-ecobee3_remote_sensor", + "swVersion": "250206213001", + "hwVersion": "250206213001", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json new file mode 100644 index 00000000000..9becb0923c2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json @@ -0,0 +1,80 @@ +{ + "items": [ + { + "deviceId": "028469cb-6e89-4f14-8d9a-bfbca5e0fbfc", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Main Floor", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.276Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-thermostat", + "swVersion": "250206151734", + "hwVersion": "250206151734", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json new file mode 100644 index 00000000000..7b8e174d420 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -0,0 +1,50 @@ +{ + "items": [ + { + "deviceId": "f1af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "fake-fan", + "label": "Fake fan", + "manufacturerName": "Myself", + "presentationId": "3f0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "components": [ + { + "id": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "parentDeviceId": "6a2dd7a4-dd77-48bc-9acf-017029aaf099", + "profile": { + "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..910eacec2cc --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -0,0 +1,65 @@ +{ + "items": [ + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "name": "GE Dimmer Switch", + "label": "Basement Exit Light", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", + "deviceManufacturerCode": "0063-4944-3130", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "e73dcd00-6953-431d-ae79-73fd2f2c528e", + "components": [ + { + "id": "main", + "label": "Basement Exit Light", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + }, + { + "name": "Switch", + "categoryType": "user" + } + ] + } + ], + "createTime": "2020-05-25T18:18:01Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" + }, + "zwave": { + "networkId": "14", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 99, + "productType": 18756, + "productId": 12592 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..7f729001453 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "440063de-a200-40b5-8a6b-f3399eaa0370", + "name": "hue-color-temperature-bulb", + "label": "Bathroom spot", + "manufacturerName": "0A2r", + "presentationId": "ST_b93bec0e-1a81-4471-83fc-4dddca504acd", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.453Z", + "profile": { + "id": "a79e4507-ecaa-3c7e-b660-a3a71f30eafb" + }, + "viper": { + "uniqueIdentifier": "ea409b82a6184ad9b49bd6318692cc1c", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue ambiance spot", + "swVersion": "1.122.2", + "hwVersion": "LTG002", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..eeca03fec01 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "cb958955-b015-498c-9e62-fc0c51abd054", + "name": "hue-rgbw-color-bulb", + "label": "Standing light", + "manufacturerName": "0A2r", + "presentationId": "ST_2733b8dc-4b0f-4593-8e49-2432202abd52", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorControl", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "samsungim.hueSyncMode", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.454Z", + "profile": { + "id": "71be1b96-c5b5-38f7-a22c-65f5392ce7ed" + }, + "viper": { + "uniqueIdentifier": "f5f891a57b9d45408230b4228bdc2111", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue color lamp", + "swVersion": "1.122.2", + "hwVersion": "LCA001", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json new file mode 100644 index 00000000000..3fc26307c90 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -0,0 +1,41 @@ +{ + "items": [ + { + "deviceId": "184c67cc-69e2-44b6-8f73-55c963068ad9", + "name": "iPhone", + "label": "iPhone", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-Mobile_Presence", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "presenceSensor", + "version": 1 + } + ], + "categories": [ + { + "name": "MobilePresence", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-12-02T16:14:24.394Z", + "parentDeviceId": "b8e11599-5297-4574-8e62-885995fcaa20", + "profile": { + "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" + }, + "type": "MOBILE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json new file mode 100644 index 00000000000..3770614a366 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "name": "Multipurpose Sensor", + "label": "Deck Door", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "b277a3c0-b8fe-44de-9133-c1108747810c", + "components": [ + { + "id": "main", + "label": "Deck Door", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "Door", + "categoryType": "user" + } + ] + } + ], + "createTime": "2019-02-23T16:53:57Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010AED6B", + "networkId": "C972", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..ae6596755a3 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "bf4b1167-48a3-4af7-9186-0900a678ffa5", + "name": "sensibo-airconditioner-1", + "label": "Office", + "manufacturerName": "0ABU", + "presentationId": "sensibo-airconditioner-1", + "deviceManufacturerCode": "Sensibo", + "locationId": "fe14085e-bacb-4997-bc0c-df08204eaea2", + "ownerId": "49228038-22ca-1c78-d7ab-b774b4569480", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-04T10:10:02.873Z", + "profile": { + "id": "ddaffb28-8ebb-4bd6-9d6f-57c28dcb434d" + }, + "viper": { + "manufacturerName": "Sensibo", + "modelName": "skyplus", + "swVersion": "SKY40147", + "hwVersion": "SKY40147", + "endpointAppId": "viper_5661d200-806e-11e9-abe0-3b2f83c8954c" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json new file mode 100644 index 00000000000..24d0fbc6e84 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "name": "SYLVANIA SMART+ Smart Plug", + "label": "Arlo Beta Basestation", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "28127039-043b-3df0-adf2-7541403dc4c1", + "deviceManufacturerCode": "LEDVANCE", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "Pi Hole", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-10-05T12:23:14Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" + }, + "zigbee": { + "eui": "F0D1B80000051E05", + "networkId": "801E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json new file mode 100644 index 00000000000..67d1ef24cf9 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "deviceId": "c85fced9-c474-4a47-93c2-037cc7829536", + "name": "sonos-player", + "label": "Elliots Rum", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ef0a871d-9ed1-377d-8746-0da1dfd50598", + "deviceManufacturerCode": "Sonos", + "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", + "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", + "roomId": "105e6d1a-52a4-4797-a235-5a48d7d433c8", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaGroup", + "version": 1 + }, + { + "id": "mediaPresets", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Speaker", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-02T13:18:28.570Z", + "parentDeviceId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", + "profile": { + "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" + }, + "lan": { + "networkId": "38420B9108F6", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "executingLocally": true, + "hubId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json new file mode 100644 index 00000000000..7fb07533810 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -0,0 +1,109 @@ +{ + "items": [ + { + "deviceId": "0d94e5db-8501-2355-eb4f-214163702cac", + "name": "Soundbar", + "label": "Soundbar Living", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-002S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "db506ec3-83b1-4125-9c4c-eb597da5db6a", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.soundFrom", + "version": 1 + }, + { + "id": "samsungvd.thingStatus", + "version": 1 + }, + { + "id": "samsungvd.audioGroupInfo", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-10-26T02:58:40.549Z", + "profile": { + "id": "3a714028-20ea-3feb-9891-46092132c737" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Soundbar Living", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "HW-Q990C", + "platformVersion": "7.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "SAT-iMX8M23WWC-1010.5", + "vendorId": "VD-NetworkAudio-002S", + "vendorResourceClientServerVersion": "3.2.41", + "lastSignupTime": "2024-10-26T02:58:36.491256384Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json new file mode 100644 index 00000000000..3c22a214495 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -0,0 +1,148 @@ +{ + "items": [ + { + "deviceId": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "name": "[TV] Samsung 8 Series (49)", + "label": "[TV] Samsung 8 Series (49)", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-STV_2017_K", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "deviceTypeName": "Samsung OCF TV", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "tvChannel", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "custom.error", + "version": 1 + }, + { + "id": "custom.picturemode", + "version": 1 + }, + { + "id": "custom.soundmode", + "version": 1 + }, + { + "id": "custom.accessibility", + "version": 1 + }, + { + "id": "custom.launchapp", + "version": 1 + }, + { + "id": "custom.recording", + "version": 1 + }, + { + "id": "custom.tvsearch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungvd.ambient", + "version": 1 + }, + { + "id": "samsungvd.ambientContent", + "version": 1 + }, + { + "id": "samsungvd.mediaInputSource", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungvd.firmwareVersion", + "version": 1 + }, + { + "id": "samsungvd.supportsPowerOnByOcf", + "version": 1 + } + ], + "categories": [ + { + "name": "Television", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-05-07T02:58:10Z", + "profile": { + "id": "bac5c673-8eea-3d00-b1d2-283b46539017" + }, + "ocf": { + "ocfDeviceType": "oic.d.tv", + "name": "[TV] Samsung 8 Series (49)", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "UN49MU8000", + "platformVersion": "Tizen 3.0", + "platformOS": "4.1.10", + "hwVersion": "0-0", + "firmwareVersion": "T-KTMAKUC-1290.3", + "vendorId": "VD-STV_2017_K", + "locale": "en_US", + "lastSignupTime": "2021-08-21T18:52:56.748359Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json new file mode 100644 index 00000000000..d5bf3b32a0c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -0,0 +1,69 @@ +{ + "items": [ + { + "deviceId": "2894dc93-0f11-49cc-8a81-3a684cebebf6", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "battery", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T22:04:56.174Z", + "profile": { + "id": "e921d7f2-5851-363d-89d5-5e83f5ab44c6" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json new file mode 100644 index 00000000000..1988617afad --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + "name": "volvo", + "label": "volvo", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "valve", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "WaterValve", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-11T11:27:02.052Z", + "profile": { + "id": "f8e25992-7f5d-31da-b04d-497012590113" + }, + "virtual": { + "name": "volvo", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json new file mode 100644 index 00000000000..ad3a45a0481 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "deviceId": "a2a6018b-2663-4727-9d1d-8f56953b5116", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "838ae989-b832-3610-968c-2940491600f6", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "waterSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "LeakSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T21:58:18.688Z", + "profile": { + "id": "39230a95-d42d-34d4-a33c-f79573495a30" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..e83a1be7644 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -0,0 +1,67 @@ +{ + "items": [ + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "name": "Yale Push Button Deadbolt Lock", + "label": "Basement Door Lock", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "45f9424f-4e20-34b0-abb6-5f26b189acb0", + "deviceManufacturerCode": "Yale", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "Basement Door Lock", + "capabilities": [ + { + "id": "lock", + "version": 1 + }, + { + "id": "lockCodes", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartLock", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-18T23:01:19Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" + }, + "zigbee": { + "eui": "000D6F0002FB6E24", + "networkId": "C771", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/locations.json b/tests/components/smartthings/fixtures/locations.json new file mode 100644 index 00000000000..abfa17dc4b7 --- /dev/null +++ b/tests/components/smartthings/fixtures/locations.json @@ -0,0 +1,9 @@ +{ + "items": [ + { + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236c", + "name": "Home" + } + ], + "_links": null +} diff --git a/tests/components/smartthings/fixtures/scenes.json b/tests/components/smartthings/fixtures/scenes.json new file mode 100644 index 00000000000..aa4f1aaa3d1 --- /dev/null +++ b/tests/components/smartthings/fixtures/scenes.json @@ -0,0 +1,34 @@ +{ + "items": [ + { + "sceneId": "743b0f37-89b8-476c-aedf-eea8ad8cd29d", + "sceneName": "Away", + "sceneIcon": "203", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964737000, + "lastUpdatedDate": 1738964737000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + }, + { + "sceneId": "f3341e8b-9b32-4509-af2e-4f7c952e98ba", + "sceneName": "Home", + "sceneIcon": "204", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964731000, + "lastUpdatedDate": 1738964731000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + } + ], + "_links": { + "next": null, + "previous": null + } +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1317c19edd7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -0,0 +1,529 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': '2nd Floor Hallway motion', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway sound', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound', + 'friendly_name': '2nd Floor Hallway sound', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': '.Front Door Open/Closed Sensor contact', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator contact', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.child_bedroom_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Child Bedroom motion', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.child_bedroom_presence', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Child Bedroom presence', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iphone_presence', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'iPhone presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'iPhone presence', + }), + 'context': , + 'entity_id': 'binary_sensor.iphone_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_acceleration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door acceleration', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moving', + 'friendly_name': 'Deck Door acceleration', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_acceleration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door contact', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.volvo_valve', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'volvo valve', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'volvo valve', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.asd_water', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd water', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'asd water', + }), + 'context': , + 'entity_id': 'binary_sensor.asd_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr new file mode 100644 index 00000000000..bd76637cfb7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -0,0 +1,356 @@ +# serializer version: 1 +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.ac_office_granit', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'drlc_status_duration': 0, + 'drlc_status_level': -1, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'AC Office Granit', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': None, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.ac_office_granit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aire_dormitorio_principal', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'drlc_status_duration': 0, + 'drlc_status_level': 0, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'high', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Aire Dormitorio Principal', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.aire_dormitorio_principal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.main_floor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main Floor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 32, + 'current_temperature': 21.7, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Main Floor', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 21.7, + }), + 'context': , + 'entity_id': 'climate.main_floor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + ]), + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.asd', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'asd', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4734.6, + 'fan_mode': 'followschedule', + 'fan_modes': list([ + 'on', + ]), + 'friendly_name': 'asd', + 'hvac_action': , + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.asd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr new file mode 100644 index 00000000000..6283e4fef04 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -0,0 +1,100 @@ +# serializer version: 1 +# name: test_all_entities[c2c_shade][cover.curtain_1a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.curtain_1a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain 1A', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '571af102-15db-4030-b76b-245a691f74a5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_shade][cover.curtain_1a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'shade', + 'friendly_name': 'Curtain 1A', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.curtain_1a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.microwave', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Microwave', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Microwave', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.microwave', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr new file mode 100644 index 00000000000..400ceef8390 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_all_entities[fake_fan][fan.fake_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fake_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fake fan', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fake_fan][fan.fake_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fake fan', + 'percentage': 2000, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fake_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr new file mode 100644 index 00000000000..546d99a967f --- /dev/null +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -0,0 +1,1024 @@ +# serializer version: 1 +# name: test_devices[aeotec_home_energy_meter_gen5] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f0af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeotec Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[base_electric_meter] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '68e786a6-7f61-4c3a-9e13-70b803cf782b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeon Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_arlo_pro_3_switch] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': '2nd Floor Hallway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_shade] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '571af102-15db-4030-b76b-245a691f74a5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Curtain 1A', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[centralite] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd0268a69-abfb-4c92-a646-61cec2e510ad', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Dimmer Debian', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[contact_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2d9a892b-1c93-45a5-84cb-0e81889498c6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': '.Front Door Open/Closed Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '96a5ef74-5832-a84b-f1f7-ca799957065d', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model_id': None, + 'name': 'AC Office Granit', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4ece486b-89db-f06a-d54d-748b676b4d8e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model_id': None, + 'name': 'Aire Dormitorio Principal', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ks_microwave_0101x] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2bad3237-4886-e699-1b90-4a51a3d55c8a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model_id': None, + 'name': 'Microwave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ref_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7db87911-7dce-1cf2-7119-b953432a2f09', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model_id': None, + 'name': 'Refrigerator', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_rvc_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model_id': None, + 'name': 'Robot vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_dw_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model_id': None, + 'name': 'Dishwasher', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_DW_A51_20_COMMON_30230714', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wd_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '02f7256e-8353-5bdd-547f-bd5b1647e01b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model_id': None, + 'name': 'Dryer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f984b91d-f250-9d42-3436-33f09a422a47', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model_id': None, + 'name': 'Washer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd5dc3299-c266-41c7-bd08-f540aea54b89', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Child Bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Main Floor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[fake_fan] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Fake fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[ge_in_wall_smart_dimmer] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Exit Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_color_temperature_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '440063de-a200-40b5-8a6b-f3399eaa0370', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Bathroom spot', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_rgbw_color_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'cb958955-b015-498c-9e62-fc0c51abd054', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Standing light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[iphone] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '184c67cc-69e2-44b6-8f73-55c963068ad9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'iPhone', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[multipurpose_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7d246592-93db-4d72-a10d-5a51793ece8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sensibo_airconditioner_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[smart_plug] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '550a1c72-65a0-4d55-b97b-75168e055398', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Arlo Beta Basestation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sonos_player] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c85fced9-c474-4a47-93c2-037cc7829536', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Elliots Rum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_network_audio_002s] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '0d94e5db-8501-2355-eb4f-214163702cac', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'HW-Q990C', + 'model_id': None, + 'name': 'Soundbar Living', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'SAT-iMX8M23WWC-1010.5', + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_stv_2017_k] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0-0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'UN49MU8000', + 'model_id': None, + 'name': '[TV] Samsung 8 Series (49)', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'T-KTMAKUC-1290.3', + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2894dc93-0f11-49cc-8a81-3a684cebebf6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_valve] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'volvo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_water_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a2a6018b-2663-4727-9d1d-8f56953b5116', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[yale_push_button_deadbolt_lock] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a9f587c5-5d8b-4273-8907-e7f609af5158', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Door Lock', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr new file mode 100644 index 00000000000..8e7f424f658 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -0,0 +1,267 @@ +# serializer version: 1 +# name: test_all_entities[centralite][light.dimmer_debian-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_debian', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimmer Debian', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][light.dimmer_debian-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer Debian', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_debian', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.basement_exit_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Exit Light', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Basement Exit Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.basement_exit_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bathroom_spot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bathroom spot', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 178, + 'color_mode': , + 'color_temp': 333, + 'color_temp_kelvin': 3000, + 'friendly_name': 'Bathroom spot', + 'hs_color': tuple( + 27.825, + 56.895, + ), + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': tuple( + 255, + 177, + 110, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.bathroom_spot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.standing_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Standing light', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Standing light', + 'hs_color': None, + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.standing_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr new file mode 100644 index 00000000000..94370f8570b --- /dev/null +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.basement_door_lock', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Door Lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Basement Door Lock', + 'lock_state': 'locked', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.basement_door_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_scene.ambr b/tests/components/smartthings/snapshots/test_scene.ambr new file mode 100644 index 00000000000..fd9abc9fcca --- /dev/null +++ b/tests/components/smartthings/snapshots/test_scene.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_all_entities[scene.away-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.away', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '743b0f37-89b8-476c-aedf-eea8ad8cd29d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Away', + 'icon': '203', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[scene.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.home', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f3341e8b-9b32-4509-af2e-4f7c952e98ba', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Home', + 'icon': '204', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..92928b9606b --- /dev/null +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -0,0 +1,4857 @@ +# serializer version: 1 +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Energy Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeotec Energy Monitor Energy Meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19978.536', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeotec Energy Monitor Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2859.743', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Voltage Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.voltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aeotec Energy Monitor Voltage Measurement', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeon Energy Monitor Energy Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeon Energy Monitor Energy Meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1930.362', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeon Energy Monitor Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeon Energy Monitor Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '938.3', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '2nd Floor Hallway Alarm', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '2nd Floor Hallway Alarm', + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '2nd Floor Hallway Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dimmer_debian_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dimmer Debian Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dimmer Debian Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.dimmer_debian_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '.Front Door Open/Closed Sensor Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Air Quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Air Quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Dust Level', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2247.3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_fine_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Fine Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Fine Dust Level', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_fine_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AC Office Granit power', + 'power_consumption_end': '2025-02-09T16:15:33Z', + 'power_consumption_start': '2025-02-09T15:45:29Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AC Office Granit Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Air Quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Air Quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Dust Level', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.836', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Fine Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Fine Dust Level', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Odor Sensor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Odor Sensor', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aire Dormitorio Principal power', + 'power_consumption_end': '2025-02-09T17:02:44Z', + 'power_consumption_start': '2025-02-09T16:08:15Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Aire Dormitorio Principal Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aire Dormitorio Principal Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Completion Time', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T21:13:36.184Z', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Job State', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Machine State', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.microwave_oven_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Mode', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Others', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_set_point', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Set Point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Set Point', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Microwave Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Microwave Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.microwave_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1568.087', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Refrigerator power', + 'power_consumption_end': '2025-02-09T17:49:00Z', + 'power_consumption_start': '2025-02-09T17:38:01Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0135559777781698', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Temperature Measurement', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator Thermostat Cooling Setpoint', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Thermostat Cooling Setpoint', + }), + 'context': , + 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Robot vacuum Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Movement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Movement', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Turbo Mode', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dishwasher Dishwasher Completion Time', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T22:49:26+00:00', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_dishwasher_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Dishwasher Job State', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Dishwasher Machine State', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.6', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dishwasher power', + 'power_consumption_end': '2025-02-08T20:21:26Z', + 'power_consumption_start': '2025-02-08T20:21:21Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_dryer_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer Dryer Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dryer Dryer Completion Time', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T19:25:10+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_dryer_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer Dryer Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Dryer Job State', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_dryer_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer Dryer Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Dryer Machine State', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4495.5', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer power', + 'power_consumption_end': '2025-02-08T18:10:11Z', + 'power_consumption_start': '2025-02-07T04:00:19Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '352.8', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer power', + 'power_consumption_end': '2025-02-07T03:09:45Z', + 'power_consumption_start': '2025-02-07T03:09:24Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_washer_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer Washer Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer Washer Completion Time', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-07T03:54:45+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_washer_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer Washer Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Washer Job State', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_washer_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer Washer Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Washer Machine State', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Child Bedroom Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main Floor Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Main Floor Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_floor_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main Floor Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Main Floor Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.main_floor_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.deck_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Deck Door Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.deck_door_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.4', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_x_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door X Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door X Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_x_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_y_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door Y Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Y Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_y_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_z_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door Z Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Z Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_z_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1042', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.office_air_conditioner_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Office Air Conditioner Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Air Conditioner Mode', + }), + 'context': , + 'entity_id': 'sensor.office_air_conditioner_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Office Thermostat Cooling Setpoint', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office Thermostat Cooling Setpoint', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Elliots Rum Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elliots Rum Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elliots_rum_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Elliots Rum Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elliots Rum Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_living_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Media Input Source', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HDMI1', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.asd_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'asd Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.asd_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4734.552604985020', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Door Lock Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Door Lock Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '86', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr new file mode 100644 index 00000000000..cf3245eed7d --- /dev/null +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -0,0 +1,471 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.2nd_floor_hallway', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '2nd Floor Hallway', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '2nd Floor Hallway', + }), + 'context': , + 'entity_id': 'switch.2nd_floor_hallway', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.microwave', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave', + }), + 'context': , + 'entity_id': 'switch.microwave', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.robot_vacuum', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + }), + 'context': , + 'entity_id': 'switch.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dishwasher', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher', + }), + 'context': , + 'entity_id': 'switch.dishwasher', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dryer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + }), + 'context': , + 'entity_id': 'switch.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + }), + 'context': , + 'entity_id': 'switch.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.office', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Office', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office', + }), + 'context': , + 'entity_id': 'switch.office', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.arlo_beta_basestation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Arlo Beta Basestation', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arlo Beta Basestation', + }), + 'context': , + 'entity_id': 'switch.arlo_beta_basestation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.soundbar_living', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living', + }), + 'context': , + 'entity_id': 'switch.soundbar_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.tv_samsung_8_series_49', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49)', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49)', + }), + 'context': , + 'entity_id': 'switch.tv_samsung_8_series_49', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 52fd5d28aa7..eb473d3be04 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -1,139 +1,53 @@ -"""Test for the SmartThings binary_sensor platform. +"""Test for the SmartThings binary_sensor platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES, - DOMAIN as BINARY_SENSOR_DOMAIN, -) -from homeassistant.components.smartthings import binary_sensor -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_mapping_integrity() -> None: - """Test ensures the map dicts have proper integrity.""" - # Ensure every CAPABILITY_TO_ATTRIB key is in CAPABILITIES - # Ensure every CAPABILITY_TO_ATTRIB value is in ATTRIB_TO_CLASS keys - for capability, attrib in binary_sensor.CAPABILITY_TO_ATTRIB.items(): - assert capability in CAPABILITIES, capability - assert attrib in ATTRIBUTES, attrib - assert attrib in binary_sensor.ATTRIB_TO_CLASS, attrib - # Ensure every ATTRIB_TO_CLASS value is in DEVICE_CLASSES - for attrib, device_class in binary_sensor.ATTRIB_TO_CLASS.items(): - assert attrib in ATTRIBUTES, attrib - assert device_class in DEVICE_CLASSES, device_class - - -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the light types.""" - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state.state == "off" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} {Attribute.motion}" - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Motion Sensor 1", - [Capability.motion_sensor], - { - Attribute.motion: "inactive", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.motion}" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.motion_sensor, Attribute.motion, "active" - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - config_entry = await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "binary_sensor") - # Assert - assert ( - hass.states.get("binary_sensor.motion_sensor_1_motion").state - == STATE_UNAVAILABLE + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.BINARY_SENSOR ) -async def test_entity_category( - hass: HomeAssistant, entity_registry: er.EntityRegistry, device_factory +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the light types.""" - device1 = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - device2 = device_factory( - "Tamper Sensor 2", [Capability.tamper_alert], {Attribute.tamper: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device1, device2]) + """Test state update.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.entity_category is None + assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_OFF - entry = entity_registry.async_get("binary_sensor.tamper_sensor_2_tamper") - assert entry - assert entry.entity_category is EntityCategory.DIAGNOSTIC + await trigger_update( + hass, + devices, + "7db87911-7dce-1cf2-7119-b953432a2f09", + Capability.CONTACT_SENSOR, + Attribute.CONTACT, + "open", + ) + + assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_ON diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index d39ee2d6bed..380c4072860 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -1,12 +1,11 @@ -"""Test for the SmartThings climate platform. +"""Test for the SmartThings climate platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command, Status import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -26,748 +25,835 @@ from homeassistant.components.climate import ( SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - ClimateEntityFeature, + SWING_HORIZONTAL, + SWING_OFF, HVACAction, HVACMode, ) -from homeassistant.components.smartthings import climate -from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry -@pytest.fixture(name="legacy_thermostat") -def legacy_thermostat_fixture(device_factory): - """Fixture returns a legacy thermostat.""" - device = device_factory( - "Legacy Thermostat", - capabilities=[Capability.thermostat], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "auto", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "auto", - Attribute.supported_thermostat_modes: climate.MODE_TO_STATE.keys(), - Attribute.thermostat_operating_state: "idle", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="basic_thermostat") -def basic_thermostat_fixture(device_factory): - """Fixture returns a basic thermostat.""" - device = device_factory( - "Basic Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "auto", "heat", "cool"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="minimal_thermostat") -def minimal_thermostat_fixture(device_factory): - """Fixture returns a minimal thermostat without cooling.""" - device = device_factory( - "Minimal Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "heat"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="thermostat") -def thermostat_fixture(device_factory): - """Fixture returns a fully-featured thermostat.""" - device = device_factory( - "Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.relative_humidity_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - Capability.thermostat_fan_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "on", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "heat", - Attribute.supported_thermostat_modes: [ - "auto", - "heat", - "cool", - "off", - "eco", - ], - Attribute.thermostat_operating_state: "idle", - Attribute.humidity: 34, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="buggy_thermostat") -def buggy_thermostat_fixture(device_factory): - """Fixture returns a buggy thermostat.""" - device = device_factory( - "Buggy Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.thermostat_mode: "heating", - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="air_conditioner") -def air_conditioner_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "fanOnly", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -@pytest.fixture(name="air_conditioner_windfree") -def air_conditioner_windfree_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "wind", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -async def test_legacy_thermostat_entity_state( - hass: HomeAssistant, legacy_thermostat +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[legacy_thermostat]) - state = hass.states.get("climate.legacy_thermostat") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "auto" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20 # celsius - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 23.3 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.CLIMATE) -async def test_basic_thermostat_entity_state( - hass: HomeAssistant, basic_thermostat +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[basic_thermostat]) - state = hass.states.get("climate.basic_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test climate set fan mode.""" + await setup_integration(hass, mock_config_entry) - -async def test_minimal_thermostat_entity_state( - hass: HomeAssistant, minimal_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[minimal_thermostat]) - state = hass.states.get("climate.minimal_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.HEAT, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - - -async def test_thermostat_entity_state(hass: HomeAssistant, thermostat) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "on" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TEMPERATURE] == 20 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_CURRENT_HUMIDITY] == 34 - - -async def test_buggy_thermostat_entity_state( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.state == STATE_UNKNOWN - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.state is STATE_UNKNOWN - assert state.attributes[ATTR_TEMPERATURE] is None - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_HVAC_MODES] == [] - - -async def test_buggy_thermostat_invalid_mode( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests when an invalid operation mode is included.""" - buggy_thermostat.status.update_attribute_value( - Attribute.supported_thermostat_modes, ["heat", "emergency heat", "other"] - ) - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - - -async def test_air_conditioner_entity_state( - hass: HomeAssistant, air_conditioner -) -> None: - """Tests when an invalid operation mode is included.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.DRY, - HVACMode.FAN_ONLY, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "medium" - assert sorted(state.attributes[ATTR_FAN_MODES]) == [ - "auto", - "high", - "low", - "medium", - "turbo", - ] - assert state.attributes[ATTR_TEMPERATURE] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 24 - assert state.attributes["drlc_status_duration"] == 0 - assert state.attributes["drlc_status_level"] == -1 - assert state.attributes["drlc_status_start"] == "1970-01-01T00:00:00Z" - assert state.attributes["drlc_status_override"] is False - - -async def test_set_fan_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the fan mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "auto"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_FAN_MODE: "auto"}, blocking=True, ) - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.attributes[ATTR_FAN_MODE] == "auto", entity_id + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="auto", + ) -async def test_set_hvac_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the hvac mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode to off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_HVAC_MODE: HVACMode.COOL}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.state == HVACMode.COOL, entity_id - - -async def test_ac_set_hvac_mode_from_off(hass: HomeAssistant, air_conditioner) -> None: - """Test setting HVAC mode when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("hvac_mode", "argument"), + [ + (HVACMode.HEAT_COOL, "auto"), + (HVACMode.COOL, "cool"), + (HVACMode.DRY, "dry"), + (HVACMode.HEAT, "heat"), + (HVACMode.FAN_ONLY, "fanOnly"), + ], +) +async def test_ac_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + argument: str, +) -> None: + """Test setting AC HVAC mode.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_turns_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode turns on the device if it is off.""" + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.air_conditioner", + ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_ac_set_hvac_mode_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the AC HVAC mode can be turned off set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.OFF}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_ac_set_hvac_mode_wind( - hass: HomeAssistant, air_conditioner_windfree + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the AC HVAC mode to fan only as wind mode for supported models.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner_windfree]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF + """Test setting AC HVAC mode to wind if the device supports it.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "wind"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.FAN_ONLY + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="wind", + ) -async def test_set_temperature_heat_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in heat mode.""" - thermostat.status.thermostat_mode = "heat" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_TEMPERATURE: 23}, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 21 - assert thermostat.status.heating_setpoint == 69.8 - - -async def test_set_temperature_cool_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in cool mode.""" - thermostat.status.thermostat_mode = "cool" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TEMPERATURE] == 21 -async def test_set_temperature(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully.""" - thermostat.status.thermostat_mode = "auto" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_while_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature and HVAC mode while off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac(hass: HomeAssistant, air_conditioner) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_TEMPERATURE: 27}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - - -async def test_set_temperature_ac_with_mode( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + """Test setting AC temperature and HVAC mode.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac_with_mode_from_off( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temp and mode is set successfully when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" - ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state == HVACMode.OFF + """Test setting AC temperature and HVAC mode OFF.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL - - -async def test_set_temperature_ac_with_mode_to_off( - hass: HomeAssistant, air_conditioner -) -> None: - """Test the temp and mode is set successfully to turn off the unit.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + ] -async def test_set_temperature_with_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature and mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, - ATTR_HVAC_MODE: HVACMode.HEAT_COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 - assert state.state == HVACMode.HEAT_COOL - - -async def test_set_turn_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned off successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_OFF, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - - -async def test_set_turn_on(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned on successfully.""" - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_ON, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_entity_and_device_attributes( +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_ac_toggle_power( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - thermostat, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: Command, ) -> None: - """Test the attributes of the entries are correct.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + """Test toggling AC power.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("climate.thermostat") - assert entry - assert entry.unique_id == thermostat.device_id - - entry = device_registry.async_get_device( - identifiers={(DOMAIN, thermostat.device_id)} - ) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, thermostat.device_id)} - assert entry.name == thermostat.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_set_windfree_off(hass: HomeAssistant, air_conditioner) -> None: - """Test if the windfree preset can be turned on and is turned off when fan mode is set.""" - entity_ids = ["climate.air_conditioner"] - air_conditioner.status.update_attribute_value(Attribute.switch, "on") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_PRESET_MODE: "windFree"}, + service, + {ATTR_ENTITY_ID: "climate.ac_office_granit"}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_PRESET_MODE] == "windFree" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "low"}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + command, + MAIN, ) - state = hass.states.get("climate.air_conditioner") - assert not state.attributes[ATTR_PRESET_MODE] -async def test_set_swing_mode(hass: HomeAssistant, air_conditioner) -> None: - """Test the fan swing is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - entity_ids = ["climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_swing_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set swing mode.""" + set_attribute_value( + devices, + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ["fixed"], + ) + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_SWING_MODE: "vertical"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_SWING_MODE: SWING_OFF}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_SWING_MODE] == "vertical" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + MAIN, + argument="fixed", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set preset mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: "windFree"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + MAIN, + argument="windFree", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Attribute.SWITCH, + "on", + ) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.HEAT + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 25, + 20, + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.FAN_MODE, + "auto", + ATTR_FAN_MODE, + "low", + "auto", + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.SUPPORTED_AC_FAN_MODES, + ["low", "auto"], + ATTR_FAN_MODES, + ["auto", "low", "medium", "high", "turbo"], + ["low", "auto"], + ), + ( + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT, + 23, + ATTR_TEMPERATURE, + 25, + 23, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "horizontal", + ATTR_SWING_MODE, + SWING_OFF, + SWING_HORIZONTAL, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "direct", + ATTR_SWING_MODE, + SWING_OFF, + SWING_OFF, + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_TEMPERATURE, + ATTR_SWING_MODE, + f"{ATTR_SWING_MODE}_off", + ], +) +async def test_ac_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == original_value + ) + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + capability, + attribute, + value, + ) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == expected_value + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set fan mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_FAN_MODE: "on"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + MAIN, + argument="on", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set HVAC mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="auto", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ("state", "data", "calls"), + [ + ( + "auto", + {ATTR_TARGET_TEMP_LOW: 15, ATTR_TARGET_TEMP_HIGH: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=59.0, + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ( + "cool", + {ATTR_TEMPERATURE: 15}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=59.0, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=73.4, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.COOL}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="cool", + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ], +) +async def test_thermostat_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + state: str, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test thermostat set temperature.""" + set_attribute_value( + devices, Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE, state + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.asd"} | data, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == calls + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_updating_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + Attribute.HUMIDITY, + 40, + ) + + assert hass.states.get("climate.asd").attributes[ATTR_CURRENT_HUMIDITY] == 40 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 4734.6, + -6.7, + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.THERMOSTAT_FAN_MODE, + "auto", + ATTR_FAN_MODE, + "followschedule", + "auto", + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.SUPPORTED_THERMOSTAT_FAN_MODES, + ["auto", "circulate"], + ATTR_FAN_MODES, + ["on"], + ["auto", "circulate"], + ), + ( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + "fan only", + ATTR_HVAC_ACTION, + HVACAction.COOLING, + HVACAction.FAN, + ), + ( + Capability.THERMOSTAT_MODE, + Attribute.SUPPORTED_THERMOSTAT_MODES, + ["coolClean", "dryClean"], + ATTR_HVAC_MODES, + [], + [HVACMode.COOL, HVACMode.DRY], + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ], +) +async def test_thermostat_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.asd").attributes[state_attribute] == original_value + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + capability, + attribute, + value, + ) + + assert hass.states.get("climate.asd").attributes[state_attribute] == expected_value diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 05ddc3a71de..647e0ea5284 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -1,813 +1,436 @@ """Tests for the SmartThings config flow module.""" from http import HTTPStatus -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError -from pysmartthings.installedapp import format_install_url +import pytest -from homeassistant import config_entries -from homeassistant.components.smartthings import smartapp +from homeassistant.components.smartthings import OLD_DATA from homeassistant.components.smartthings.const import ( - CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config 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 -async def test_import_shows_user_step(hass: HomeAssistant) -> None: - """Test import source shows the user form.""" - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - -async def test_entry_created( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: - """Test local webhook, new app, install event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown + """Check a full flow.""" 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id + DOMAIN, context={"source": SOURCE_USER} ) - -async def test_entry_created_from_update_event( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test local webhook, new app, update event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_update(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_new_oauth_client( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and generation of a new oauth client.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.generate_app_oauth.return_value = app_oauth_client - smartthings_mock.locations.return_value = [location] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_copies_oauth_client( - hass: HomeAssistant, app, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and copies the oauth client from another entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - oauth_client_id = str(uuid4()) - oauth_client_secret = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_CLIENT_ID: oauth_client_id, - CONF_CLIENT_SECRET: oauth_client_secret, - CONF_LOCATION_ID: str(uuid4()), - CONF_INSTALLED_APP_ID: str(uuid4()), - CONF_ACCESS_TOKEN: token, - }, - ) - entry.add_to_hass(hass) - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - # Assert access token is defaulted to an existing entry for convenience. - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == oauth_client_secret - assert result["data"][CONF_CLIENT_ID] == oauth_client_id - assert result["title"] == location.name - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_INSTALLED_APP_ID] == installed_app_id - ), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_with_cloudhook( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test cloud, new app, install event creates entry.""" - hass.config.components.add("cloud") - # Unload the endpoint so we can reload it under the cloud. - await smartapp.unload_smartapp_endpoint(hass) - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - smartthings_mock.locations = AsyncMock(return_value=[location]) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - with ( - patch.object( - smartapp.cloud, - "async_active_subscription", - Mock(return_value=True), - ), - patch.object( - smartapp.cloud, - "async_create_cloudhook", - AsyncMock(return_value="http://cloud.test"), - ) as mock_create_cloudhook, - ): - await smartapp.setup_smartapp_endpoint(hass, True) - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - # One is done by app fixture, one done by new config entry - assert mock_create_cloudhook.call_count == 2 - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_invalid_webhook_aborts(hass: HomeAssistant) -> None: - """Test flow aborts if webhook is invalid.""" - # Webhook confirmation shown - await async_process_ha_core_config( + state = config_entry_oauth2_flow._encode_jwt( hass, - {"external_url": "http://example.local:8123"}, - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "invalid_webhook_url" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - assert "component_url" in result["description_placeholders"] - - -async def test_invalid_token_shows_error(hass: HomeAssistant) -> None: - """Test an error is shown for invalid token formats.""" - token = "123456789" - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_invalid_format"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unauthorized_token_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown for unauthorized token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_forbidden_token_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown for forbidden token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_webhook_problem_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there's an problem with the webhook endpoint.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - error.is_target_error = Mock(return_value=True) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "webhook_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_api_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when other API errors occur.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.BAD_REQUEST, - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_response_error_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - error = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.NOT_FOUND - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - smartthings_mock.apps.side_effect = Exception("Unknown error") - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_no_available_locations_aborts( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test select location aborts if no available locations.""" - token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id} - ) - entry.add_to_hass(hass) - - # Webhook confirmation shown - 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["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_available_locations" - - -async def test_reauth( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test reauth flow.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_CLIENT_ID: app_oauth_client.client_id, - CONF_CLIENT_SECRET: app_oauth_client.client_secret, - CONF_LOCATION_ID: location.location_id, - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_ACCESS_TOKEN: token, - CONF_REFRESH_TOKEN: "abc", + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, - unique_id=smartapp.format_unique_id(app.app_id, location.location_id), ) - entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + result["data"]["token"].pop("expires_at") + assert result["data"][CONF_TOKEN] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } + assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate entry is not able to set up.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - await smartapp.smartapp_update(hass, request, None, app) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "update_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + mock_config_entry.data["token"].pop("expires_at") + assert mock_config_entry.data[CONF_TOKEN] == { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } - assert entry.data[CONF_REFRESH_TOKEN] == refresh_token + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_config_entry.add_to_hass(hass) + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_old_config_entry.state is ConfigEntryState.LOADED + assert len(hass.config_entries.flow.async_progress()) == 0 + mock_old_config_entry.data[CONF_TOKEN].pop("expires_at") + assert mock_old_config_entry.data == { + "auth_implementation": DOMAIN, + "old_data": { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + CONF_TOKEN: { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + } + assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_wrong_location( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong location.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_location_mismatch" + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_old_config_entry.data == { + OLD_DATA: { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + } + } + assert ( + mock_old_config_entry.unique_id + == "appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c" + ) + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 31443c12ab2..37f12b44880 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -1,249 +1,192 @@ -"""Test for the SmartThings cover platform. +"""Test for the SmartThings cover platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, Status +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN as COVER_DOMAIN, +) +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, - CoverState, + STATE_OPEN, + STATE_OPENING, + Platform, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Garage", - [Capability.garage_door_control], - { - Attribute.door: "open", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.COVER) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_OPEN_COVER, Command.OPEN), + (SERVICE_CLOSE_COVER, Command.CLOSE), + ], +) +async def test_cover_open_close( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test cover open and close command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + action, + {ATTR_ENTITY_ID: "cover.curtain_1a"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + command, + MAIN, ) - # Act - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("cover.garage") - assert entry - assert entry.unique_id == device.device_id - - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" -async def test_open(hass: HomeAssistant, device_factory) -> None: - """Test the cover opens doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "closed"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closed"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "closed"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_set_position( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cover set position command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.curtain_1a", ATTR_POSITION: 25}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=25, + ) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True - ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.OPENING + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 -async def test_close(hass: HomeAssistant, device_factory) -> None: - """Test the cover closes doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "open"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "open"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery_updating( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.BATTERY, + Attribute.BATTERY, + 49, ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.CLOSING + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 49 -async def test_set_cover_position_switch_level( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the cover sets to the specific position for legacy devices that use Capability.switch_level.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.switch_level], - {Attribute.window_shade: "opening", Attribute.battery: 95, Attribute.level: 10}, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + Attribute.WINDOW_SHADE, + "opening", ) - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 + assert hass.states.get("cover.curtain_1a").state == STATE_OPENING -async def test_set_cover_position(hass: HomeAssistant, device_factory) -> None: - """Test the cover sets to the specific position.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.window_shade_level], - { - Attribute.window_shade: "opening", - Attribute.battery: 95, - Attribute.shade_level: 10, - }, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, - ) - - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 - - -async def test_set_cover_position_unsupported( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_position_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test set position does nothing when not supported by device.""" - # Arrange - device = device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {"entity_id": "all", ATTR_POSITION: 50}, - blocking=True, + """Test position update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 100 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 50, ) - state = hass.states.get("cover.shade") - assert ATTR_CURRENT_POSITION not in state.attributes - - # Ensure API was not called - - assert device._api.post_device_command.call_count == 0 - - -async def test_update_to_open_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the cover updates to open when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "open") - assert hass.states.get("cover.garage").state == CoverState.OPENING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.OPEN - - -async def test_update_to_closed_from_signal( - hass: HomeAssistant, device_factory -) -> None: - """Test the cover updates to closed when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closing"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "closed") - assert hass.states.get("cover.garage").state == CoverState.CLOSING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.CLOSED - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ) - config_entry = await setup_platform(hass, COVER_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, COVER_DOMAIN) - # Assert - assert hass.states.get("cover.garage").state == STATE_UNAVAILABLE + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index b78c453b402..58287355381 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -1,433 +1,168 @@ -"""Test for the SmartThings fan platform. +"""Test for the SmartThings fan platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Capability, Command +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, - FanEntityFeature, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the fan types.""" - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Dimmer 1 - state = hass.states.get("fan.fan_1") - assert state.state == "on" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "on", - Attribute.fan_speed: 2, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("fan.fan_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.FAN) -# Setup platform tests with varying capabilities -async def test_setup_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the mode capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -async def test_setup_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_setup_both_capabilities(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with both the mode and speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[ - Capability.switch, - Capability.fan_speed, - Capability.air_conditioner_fan_mode, - ], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -# Speed Capability Tests - - -async def test_turn_off_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_speed_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, ) -> None: - """Test the fan turns on to the specified speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + """Test turning on and off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "turn_on", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: "fan.fake_fan"}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 - - -async def test_turn_off_with_speed_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan turns off with the speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 100}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + command, + MAIN, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 0}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_set_percentage_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 0}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + Command.OFF, + MAIN, + ) -async def test_update_from_signal_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the fan is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - config_entry = await setup_platform(hass, FAN_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "fan") - # Assert - assert hass.states.get("fan.fan_1").state == STATE_UNAVAILABLE - - -# Preset Mode Tests - - -async def test_turn_off_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "on", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_turn_on_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_update_from_signal_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_set_preset_mode_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan mode.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", - "set_preset_mode", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PRESET_MODE: "low"}, + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.attributes[ATTR_PRESET_MODE] == "low" + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, + ) + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PRESET_MODE: "turbo"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="turbo", + ) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 83372b58228..be88f11903e 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,568 +1,31 @@ """Tests for the SmartThings component init module.""" -from collections.abc import Callable, Coroutine -from datetime import datetime, timedelta -from http import HTTPStatus -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientConnectionError, ClientResponseError -from pysmartthings import InstalledAppStatus, OAuthToken -import pytest +from syrupy import SnapshotAssertion -from homeassistant import config_entries -from homeassistant.components import cloud, smartthings -from homeassistant.components.smartthings.const import ( - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, -) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import device_registry as dr + +from . import setup_integration from tests.common import MockConfigEntry -async def test_migration_creates_new_flow( - hass: HomeAssistant, smartthings_mock, config_entry -) -> None: - """Test migration deletes app and creates new flow.""" - - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry(config_entry, version=1) - - await smartthings.async_migrate_entry(hass, config_entry) - await hass.async_block_till_done() - - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - - -async def test_unrecoverable_api_errors_create_new_flow( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test a new config flow is initiated when there are API errors. - - 401 (unauthorized): Occurs when the access token is no longer valid. - 403 (forbidden/not found): Occurs when the app or installed app could - not be retrieved/found (likely deleted?) - """ - - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Assert setup returns false - result = await hass.config_entries.async_setup(config_entry.entry_id) - assert not result - - assert config_entry.state == ConfigEntryState.SETUP_ERROR - - -async def test_recoverable_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for recoverable API errors.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_connection_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for connection errors.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.side_effect = ClientConnectionError() - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_base_url_no_longer_https_does_not_load( - hass: HomeAssistant, config_entry, app, smartthings_mock -) -> None: - """Test base_url no longer valid creates a new flow.""" - await async_process_ha_core_config( - hass, - {"external_url": "http://example.local:8123"}, - ) - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - - # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) - assert not result - - -async def test_unauthorized_installed_app_raises_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test config entry not ready raised when the app isn't authorized.""" - config_entry.add_to_hass(hass) - installed_app.installed_app_status = InstalledAppStatus.PENDING - - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_unauthorized_loads_platforms( +async def test_devices( hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) + device_id = devices.get_devices.return_value[0].device_id + device = device_registry.async_get_device({(DOMAIN, device_id)}) -async def test_config_entry_loads_platforms( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test config entry loads properly and proxies to platforms.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_config_entry_loads_unconnected_cloud( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test entry loads during startup when cloud isn't connected.""" - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_unload_entry(hass: HomeAssistant, config_entry) -> None: - """Test entries are unloaded correctly.""" - connect_disconnect = Mock() - smart_app = Mock() - smart_app.connect_event.return_value = connect_disconnect - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), smart_app, [], []) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] = broker - - with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=True - ) as forward_mock: - assert await smartthings.async_unload_entry(hass, config_entry) - - assert connect_disconnect.call_count == 1 - assert config_entry.entry_id not in hass.data[DOMAIN][DATA_BROKERS] - # Assert platforms unloaded - await hass.async_block_till_done() - assert forward_mock.call_count == len(PLATFORMS) - - -async def test_remove_entry( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app and app are removed up.""" - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_cloudhook( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app, app, and cloudhook are removed up.""" - hass.config.components.add("cloud") - # Arrange - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - # Act - with ( - patch.object( - cloud, "async_is_logged_in", return_value=True - ) as mock_async_is_logged_in, - patch.object(cloud, "async_delete_cloudhook") as mock_async_delete_cloudhook, - ): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert mock_async_is_logged_in.call_count == 1 - assert mock_async_delete_cloudhook.call_count == 1 - - -async def test_remove_entry_app_in_use( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test app is not removed if in use by another config entry.""" - # Arrange - config_entry.add_to_hass(hass) - data = config_entry.data.copy() - data[CONF_INSTALLED_APP_ID] = str(uuid4()) - entry2 = MockConfigEntry(version=2, domain=DOMAIN, data=data) - entry2.add_to_hass(hass) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_already_deleted( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test handles when the apps have already been removed.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_installedapp_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_installedapp_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - # Arrange - smartthings_mock.delete_installed_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_app_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - request_info = Mock(real_url="http://example.com") - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_app_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - smartthings_mock.delete_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_broker_regenerates_token(hass: HomeAssistant, config_entry) -> None: - """Test the device broker regenerates the refresh token.""" - token = Mock(OAuthToken) - token.refresh_token = str(uuid4()) - stored_action = None - config_entry.add_to_hass(hass) - - def async_track_time_interval( - hass: HomeAssistant, - action: Callable[[datetime], Coroutine[Any, Any, None] | None], - interval: timedelta, - ) -> None: - nonlocal stored_action - stored_action = action - - with patch( - "homeassistant.components.smartthings.async_track_time_interval", - new=async_track_time_interval, - ): - broker = smartthings.DeviceBroker(hass, config_entry, token, Mock(), [], []) - broker.connect() - - assert stored_action - await stored_action(None) - assert token.refresh.call_count == 1 - assert config_entry.data[CONF_REFRESH_TOKEN] == token.refresh_token - - -async def test_event_handler_dispatches_updated_devices( - hass: HomeAssistant, - config_entry, - device_factory, - event_request_factory, - event_factory, -) -> None: - """Test the event handler dispatches updated devices.""" - devices = [ - device_factory("Bedroom 1 Switch", ["switch"]), - device_factory("Bathroom 1", ["switch"]), - device_factory("Sensor", ["motionSensor"]), - device_factory("Lock", ["lock"]), - ] - device_ids = [ - devices[0].device_id, - devices[1].device_id, - devices[2].device_id, - devices[3].device_id, - ] - event = event_factory( - devices[3].device_id, - capability="lock", - attribute="lock", - value="locked", - data={"codeId": "1"}, - ) - request = event_request_factory(device_ids=device_ids, events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def signal(ids): - nonlocal called - called = True - assert device_ids == ids - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), devices, []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - for device in devices: - assert device.status.values["Updated"] == "Value" - assert devices[3].status.attributes["lock"].value == "locked" - assert devices[3].status.attributes["lock"].data == {"codeId": "1"} - - broker.disconnect() - - -async def test_event_handler_ignores_other_installed_app( - hass: HomeAssistant, config_entry, device_factory, event_request_factory -) -> None: - """Test the event handler dispatches updated devices.""" - device = device_factory("Bedroom 1 Switch", ["switch"]) - request = event_request_factory([device.device_id]) - called = False - - def signal(ids): - nonlocal called - called = True - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert not called - - broker.disconnect() - - -async def test_event_handler_fires_button_events( - hass: HomeAssistant, - config_entry, - device_factory, - event_factory, - event_request_factory, -) -> None: - """Test the event handler fires button events.""" - device = device_factory("Button 1", ["button"]) - event = event_factory( - device.device_id, capability="button", attribute="button", value="pushed" - ) - request = event_request_factory(events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def handler(evt): - nonlocal called - called = True - assert evt.data == { - "component_id": "main", - "device_id": device.device_id, - "location_id": event.location_id, - "value": "pushed", - "name": device.label, - "data": None, - } - - hass.bus.async_listen(EVENT_BUTTON, handler) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - - broker.disconnect() + assert device is not None + assert device == snapshot diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index b46188b5b5f..8d47e90c9f5 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -1,342 +1,307 @@ -"""Test for the SmartThings light platform. +"""Test for the SmartThings light platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command import pytest +from syrupy import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, DOMAIN as LIGHT_DOMAIN, ColorMode, - LightEntityFeature, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry -@pytest.fixture(name="light_devices") -def light_devices_fixture(device_factory): - """Fixture returns a set of mock light devices.""" - return [ - device_factory( - "Dimmer 1", - capabilities=[Capability.switch, Capability.switch_level], - status={Attribute.switch: "on", Attribute.level: 100}, - ), - device_factory( - "Color Dimmer 1", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - ], - status={ - Attribute.switch: "off", - Attribute.level: 0, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - }, - ), - device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "on", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 0.0, - Attribute.color_temperature: 4500, - }, - ), - ] - - -async def test_entity_state(hass: HomeAssistant, light_devices) -> None: - """Tests the state attributes properly match the light types.""" - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - - # Dimmer 1 - state = hass.states.get("light.dimmer_1") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert isinstance(state.attributes[ATTR_BRIGHTNESS], int) - assert state.attributes[ATTR_BRIGHTNESS] == 255 - - # Color Dimmer 1 - state = hass.states.get("light.color_dimmer_1") - assert state.state == "off" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - - # Color Dimmer 2 - state = hass.states.get("light.color_dimmer_2") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert ATTR_HS_COLOR not in state.attributes[ATTR_HS_COLOR] - assert isinstance(state.attributes[ATTR_COLOR_TEMP_KELVIN], int) - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4500 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Light 1", - [Capability.switch, Capability.switch_level], - { - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("light.light_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LIGHT) -async def test_turn_off(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_off", {"entity_id": "light.color_dimmer_2"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_off_with_transition(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully with transition.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_off", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_TRANSITION: 2}, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_on", {ATTR_ENTITY_ID: "light.color_dimmer_1"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_brightness(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on to the specified brightness.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - { - ATTR_ENTITY_ID: "light.color_dimmer_1", - ATTR_BRIGHTNESS: 75, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 74 - - -async def test_turn_on_with_minimal_brightness( - hass: HomeAssistant, light_devices +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ) + ], + ), + ( + {ATTR_COLOR_TEMP_KELVIN: 4000}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + MAIN, + argument=4000, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_HS_COLOR: [350, 90]}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Command.SET_COLOR, + MAIN, + argument={"hue": 97.2222, "saturation": 90.0}, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_BRIGHTNESS: 50}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 0], + ) + ], + ), + ( + {ATTR_BRIGHTNESS: 50, ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 3], + ) + ], + ), + ], +) +async def test_turn_on_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], ) -> None: - """Test lights set to lowest brightness when converted scale would be zero. + """Test light turn on command.""" + await setup_integration(hass, mock_config_entry) - SmartThings light brightness is a percentage (0-100), but Home Assistant uses a - 0-255 scale. This tests if a really low value (1-2) is passed, we don't - set the level to zero, which turns off the lights in SmartThings. - """ - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_1", ATTR_BRIGHTNESS: 2}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 3 + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + ], + ), + ( + {ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[0, 3], + ) + ], + ), + ], +) +async def test_turn_off_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test light turn off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_HS_COLOR: (180, 50)}, + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_HS_COLOR] == (180, 50) + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color temp.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP_KELVIN: 3333}, - blocking=True, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Attribute.SWITCH, + "on", ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3333 + + assert hass.states.get("light.standing_light").state == STATE_ON -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the light updates when receiving a signal.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_brightness( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test brightness update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 20, ) - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" + + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 51 -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the light is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_hs( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hue/saturation update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 218.906, + 60, + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 72.0, + 60, + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_color_temp( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color temperature update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 3000 + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 2000 ) - config_entry = await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "light") - # Assert - assert hass.states.get("light.color_dimmer_2").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 3c2a2651fb9..28191eceb9a 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -1,129 +1,85 @@ -"""Test for the SmartThings lock platform. +"""Test for the SmartThings lock platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Lock_1", - [Capability.lock], - { - Attribute.lock: "unlocked", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("lock.lock_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LOCK) -async def test_lock(hass: HomeAssistant, device_factory) -> None: - """Test the lock locks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock]) - device.status.attributes[Attribute.lock] = Status( - "unlocked", - None, - { - "method": "Manual", - "codeId": None, - "codeName": "Code 1", - "lockName": "Front Door", - "usedCode": "Code 2", - }, - ) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_LOCK, Command.LOCK), + (SERVICE_UNLOCK, Command.UNLOCK), + ], +) +async def test_lock_unlock( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test lock and unlock command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - LOCK_DOMAIN, "lock", {"entity_id": "lock.lock_1"}, blocking=True + LOCK_DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.basement_door_lock"}, + blocking=True, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" - assert state.attributes["method"] == "Manual" - assert state.attributes["lock_state"] == "locked" - assert state.attributes["code_name"] == "Code 1" - assert state.attributes["used_code"] == "Code 2" - assert state.attributes["lock_name"] == "Front Door" - assert "code_id" not in state.attributes - - -async def test_unlock(hass: HomeAssistant, device_factory) -> None: - """Test the lock unlocks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - LOCK_DOMAIN, "unlock", {"entity_id": "lock.lock_1"}, blocking=True + devices.execute_device_command.assert_called_once_with( + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + command, + MAIN, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "unlocked" -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the lock updates when receiving a signal.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "unlocked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - await device.lock(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - config_entry = await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "lock") - # Assert - assert hass.states.get("lock.lock_1").state == STATE_UNAVAILABLE + await trigger_update( + hass, + devices, + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + Attribute.LOCK, + "open", + ) + + assert hass.states.get("lock.basement_door_lock").state == LockState.UNLOCKED diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index a20db1aaae8..7ef287b9e96 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -1,52 +1,47 @@ -"""Test for the SmartThings scene platform. +"""Test for the SmartThings scene platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, scene +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Test the attributes of the entity are correct.""" - # Act - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - # Assert - entry = entity_registry.async_get("scene.test_scene") - assert entry - assert entry.unique_id == scene.scene_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SCENE) -async def test_scene_activate(hass: HomeAssistant, scene) -> None: - """Test the scene is activated.""" - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) +async def test_activate_scene( + hass: HomeAssistant, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test activating a scene.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( SCENE_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.test_scene"}, + {ATTR_ENTITY_ID: "scene.away"}, blocking=True, ) - state = hass.states.get("scene.test_scene") - assert state.attributes["icon"] == scene.icon - assert state.attributes["color"] == scene.color - assert state.attributes["location_id"] == scene.location_id - assert scene.execute.call_count == 1 - -async def test_unload_config_entry(hass: HomeAssistant, scene) -> None: - """Test the scene is removed when the config entry is unloaded.""" - # Arrange - config_entry = await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, SCENE_DOMAIN) - # Assert - assert hass.states.get("scene.test_scene").state == STATE_UNAVAILABLE + mock_smartthings.execute_scene.assert_called_once_with( + "743b0f37-89b8-476c-aedf-eea8ad8cd29d" + ) diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index a6a48202f1d..7f8464e69aa 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -1,290 +1,56 @@ -"""Test for the SmartThings sensors platform. +"""Test for the SmartThings sensors platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - EntityCategory, -) +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.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the sensor types.""" - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.sensor_1_battery") - assert state.state == "100" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Battery" - - -async def test_entity_three_axis_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", [Capability.three_axis], {Attribute.three_axis: [100, 75, 25]} - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == "100" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} X Coordinate" - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == "75" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Y Coordinate" - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == "25" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Z Coordinate" - - -async def test_entity_three_axis_invalid_state( - hass: HomeAssistant, device_factory -) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", - [Capability.three_axis], - {Attribute.three_axis: [None, None, None]}, - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == STATE_UNKNOWN - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Sensor 1", - [Capability.battery], - { - Attribute.battery: 100, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("sensor.sensor_1_battery") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.battery}" - assert entry.entity_category is EntityCategory.DIAGNOSTIC - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -async def test_energy_sensors_for_switch_device( +@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +async def test_state_update( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - { - Attribute.switch: "off", - Attribute.power: 355, - Attribute.energy: 11.422, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state + == "19978.536" ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.switch_1_energy_meter") - assert state - assert state.state == "11.422" - entry = entity_registry.async_get("sensor.switch_1_energy_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.energy}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - state = hass.states.get("sensor.switch_1_power_meter") - assert state - assert state.state == "355" - entry = entity_registry.async_get("sensor.switch_1_power_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.power}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_power_consumption_sensor( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, -) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "refrigerator", - [Capability.power_consumption_report], - { - Attribute.power_consumption: { - "energy": 1412002, - "deltaEnergy": 25, - "power": 109, - "powerEnergy": 24.304498331745464, - "persistedEnergy": 0, - "energySaved": 0, - "start": "2021-07-30T16:45:25Z", - "end": "2021-07-30T16:58:33Z", - }, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + await trigger_update( + hass, + devices, + "f0af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.ENERGY_METER, + Attribute.ENERGY, + 20000.0, ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.refrigerator_energy") - assert state - assert state.state == "1412.002" - entry = entity_registry.async_get("sensor.refrigerator_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - state = hass.states.get("sensor.refrigerator_power") - assert state - assert state.state == "109" - assert state.attributes["power_consumption_start"] == "2021-07-30T16:45:25Z" - assert state.attributes["power_consumption_end"] == "2021-07-30T16:58:33Z" - entry = entity_registry.async_get("sensor.refrigerator_power") - assert entry - assert entry.unique_id == f"{device.device_id}.power_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - device = device_factory( - "vacuum", - [Capability.power_consumption_report], - { - Attribute.power_consumption: {}, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + assert ( + hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state == "20000.0" ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.vacuum_energy") - assert state - assert state.state == "unknown" - entry = entity_registry.async_get("sensor.vacuum_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.battery, Attribute.battery, 75 - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("sensor.sensor_1_battery") - assert state is not None - assert state.state == "75" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - config_entry = await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - # Assert - assert hass.states.get("sensor.sensor_1_battery").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py deleted file mode 100644 index c7861866fad..00000000000 --- a/tests/components/smartthings/test_smartapp.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Tests for the smartapp module.""" - -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 - -from pysmartthings import CAPABILITIES, AppEntity, Capability -import pytest - -from homeassistant.components.smartthings import smartapp -from homeassistant.components.smartthings.const import ( - CONF_REFRESH_TOKEN, - DATA_MANAGER, - DOMAIN, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_update_app(hass: HomeAssistant, app) -> None: - """Test update_app does not save if app is current.""" - await smartapp.update_app(hass, app) - assert app.save.call_count == 0 - - -async def test_update_app_updated_needed(hass: HomeAssistant, app) -> None: - """Test update_app updates when an app is needed.""" - mock_app = Mock(AppEntity) - mock_app.app_name = "Test" - - await smartapp.update_app(hass, mock_app) - - assert mock_app.save.call_count == 1 - assert mock_app.app_name == "Test" - assert mock_app.display_name == app.display_name - assert mock_app.description == app.description - assert mock_app.webhook_target_url == app.webhook_target_url - assert mock_app.app_type == app.app_type - assert mock_app.single_instance == app.single_instance - assert mock_app.classifications == app.classifications - - -async def test_smartapp_update_saves_token( - hass: HomeAssistant, smartthings_mock, location, device_factory -) -> None: - """Test update saves token.""" - # Arrange - entry = MockConfigEntry( - domain=DOMAIN, data={"installed_app_id": str(uuid4()), "app_id": str(uuid4())} - ) - entry.add_to_hass(hass) - app = Mock() - app.app_id = entry.data["app_id"] - request = Mock() - request.installed_app_id = entry.data["installed_app_id"] - request.auth_token = str(uuid4()) - request.refresh_token = str(uuid4()) - request.location_id = location.location_id - - # Act - await smartapp.smartapp_update(hass, request, None, app) - # Assert - assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token - - -async def test_smartapp_uninstall(hass: HomeAssistant, config_entry) -> None: - """Test the config entry is unloaded when the app is uninstalled.""" - config_entry.add_to_hass(hass) - app = Mock() - app.app_id = config_entry.data["app_id"] - request = Mock() - request.installed_app_id = config_entry.data["installed_app_id"] - - with patch.object(hass.config_entries, "async_remove") as remove: - await smartapp.smartapp_uninstall(hass, request, None, app) - assert remove.call_count == 1 - - -async def test_smartapp_webhook(hass: HomeAssistant) -> None: - """Test the smartapp webhook calls the manager.""" - manager = Mock() - manager.handle_request = AsyncMock(return_value={}) - hass.data[DOMAIN][DATA_MANAGER] = manager - request = Mock() - request.headers = [] - request.json = AsyncMock(return_value={}) - result = await smartapp.smartapp_webhook(hass, "", request) - - assert result.body == b"{}" - - -async def test_smartapp_sync_subscriptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization adds and removes and ignores unused.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.thermostat), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch, Capability.execute]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 - - -async def test_smartapp_sync_subscriptions_up_to_date( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 0 - assert smartthings_mock.create_subscription.call_count == 0 - - -async def test_smartapp_sync_subscriptions_limit_warning( - hass: HomeAssistant, - smartthings_mock, - device_factory, - subscription_factory, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test synchronization over the limit logs a warning.""" - smartthings_mock.subscriptions.return_value = [] - devices = [ - device_factory("", CAPABILITIES), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert ( - "Some device attributes may not receive push updates and there may be " - "subscription creation failures" in caplog.text - ) - - -async def test_smartapp_sync_subscriptions_handles_exceptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.delete_subscription.side_effect = Exception - smartthings_mock.create_subscription.side_effect = Exception - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.thermostat, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index fadd7600e87..a1e420a8edb 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -1,115 +1,89 @@ -"""Test for the SmartThings switch platform. +"""Test for the SmartThings switch platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from homeassistant.components.smartthings.const import MAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch], - { - Attribute.switch: "on", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("switch.switch_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SWITCH) -async def test_turn_off(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "on"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.switch_1"}, blocking=True + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.2nd_floor_hallway"}, + blocking=True, ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - {Attribute.switch: "off", Attribute.power: 355, Attribute.energy: 11.422}, + devices.execute_device_command.assert_called_once_with( + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", Capability.SWITCH, command, MAIN ) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.switch_1"}, blocking=True + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + await trigger_update( + hass, + devices, + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + Capability.SWITCH, + Attribute.SWITCH, + "off", ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the switch updates when receiving a signal.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "off"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the switch is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Switch 1", [Capability.switch], {Attribute.switch: "on"}) - config_entry = await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "switch") - # Assert - assert hass.states.get("switch.switch_1").state == STATE_UNAVAILABLE + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF From 7e97ef588b8ea7e12d8356f6a9c55c79669a1691 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 15:27:52 +0100 Subject: [PATCH 1289/1435] Add keys initiate_flow and entry_type to data entry translations (#138882) --- homeassistant/components/kitchen_sink/strings.json | 8 ++++++-- script/hassfest/translations.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index e2fbb99c89f..e0cdf75b707 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -11,7 +11,6 @@ }, "config_subentries": { "entity": { - "title": "Add entity", "step": { "add_sensor": { "description": "Configure the new sensor", @@ -27,7 +26,12 @@ "state": "Initial state" } } - } + }, + "initiate_flow": { + "user": "Add sensor", + "reconfigure": "Reconfigure sensor" + }, + "entry_type": "Sensor" } }, "options": { diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2e5ec3e8ba0..c257f185f51 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -185,6 +185,8 @@ def gen_data_entry_schema( vol.Optional("abort"): {str: translation_value_validator}, vol.Optional("progress"): {str: translation_value_validator}, vol.Optional("create_entry"): {str: translation_value_validator}, + vol.Optional("initiate_flow"): {str: translation_value_validator}, + vol.Optional("entry_type"): translation_value_validator, } if flow_title == REQUIRED: schema[vol.Required("title")] = translation_value_validator @@ -289,7 +291,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: gen_data_entry_schema( config=config, integration=integration, - flow_title=REQUIRED, + flow_title=REMOVED, require_step_title=False, ), slug_validator=vol.Any("_", cv.slug), From 5324f3e5420a91e308429efaae8498d1e29e31f1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Feb 2025 15:44:16 +0100 Subject: [PATCH 1290/1435] Add support for swing horizontal mode for mqtt climate (#139303) * Add support for swing horizontal mode for mqtt climate * Fix import --- .../components/mqtt/abbreviations.py | 6 ++ homeassistant/components/mqtt/climate.py | 57 +++++++++++ tests/components/climate/common.py | 18 +++- tests/components/mqtt/test_climate.py | 96 ++++++++++++++++++- tests/components/mqtt/test_discovery.py | 1 - 5 files changed, 174 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 584b238b3a8..2d73cc5865c 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -218,10 +218,16 @@ ABBREVIATIONS = { "sup_vol": "support_volume_set", "sup_feat": "supported_features", "sup_clrm": "supported_color_modes", + "swing_h_mode_cmd_tpl": "swing_horizontal_mode_command_template", + "swing_h_mode_cmd_t": "swing_horizontal_mode_command_topic", + "swing_h_mode_stat_tpl": "swing_horizontal_mode_state_template", + "swing_h_mode_stat_t": "swing_horizontal_mode_state_topic", + "swing_h_modes": "swing_horizontal_modes", "swing_mode_cmd_tpl": "swing_mode_command_template", "swing_mode_cmd_t": "swing_mode_command_topic", "swing_mode_stat_tpl": "swing_mode_state_template", "swing_mode_stat_t": "swing_mode_state_topic", + "swing_modes": "swing_modes", "temp_cmd_tpl": "temperature_command_template", "temp_cmd_t": "temperature_command_topic", "temp_hi_cmd_tpl": "temperature_high_command_template", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index a65eb18e3f1..931a57a71cc 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -113,11 +113,19 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" CONF_PRESET_MODES_LIST = "preset_modes" + +CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" +CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" +CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" +CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" + CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" + CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" @@ -145,6 +153,8 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( climate.ATTR_MIN_TEMP, climate.ATTR_PRESET_MODE, climate.ATTR_PRESET_MODES, + climate.ATTR_SWING_HORIZONTAL_MODE, + climate.ATTR_SWING_HORIZONTAL_MODES, climate.ATTR_SWING_MODE, climate.ATTR_SWING_MODES, climate.ATTR_TARGET_TEMP_HIGH, @@ -162,6 +172,7 @@ VALUE_TEMPLATE_KEYS = ( CONF_MODE_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, CONF_SWING_MODE_STATE_TEMPLATE, CONF_TEMP_HIGH_STATE_TEMPLATE, CONF_TEMP_LOW_STATE_TEMPLATE, @@ -174,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = { CONF_MODE_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_HIGH_COMMAND_TEMPLATE, @@ -194,6 +206,8 @@ TOPIC_KEYS = ( CONF_POWER_COMMAND_TOPIC, CONF_PRESET_MODE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, CONF_SWING_MODE_COMMAND_TOPIC, CONF_SWING_MODE_STATE_TOPIC, CONF_TEMP_COMMAND_TOPIC, @@ -302,6 +316,13 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional( + CONF_SWING_HORIZONTAL_MODE_LIST, default=[SWING_ON, SWING_OFF] + ): cv.ensure_list, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( @@ -515,6 +536,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attr_fan_mode: str | None = None _attr_hvac_mode: HVACMode | None = None + _attr_swing_horizontal_mode: str | None = None _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT @@ -543,6 +565,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if (precision := config.get(CONF_PRECISION)) is not None: self._attr_precision = precision self._attr_fan_modes = config[CONF_FAN_MODE_LIST] + self._attr_swing_horizontal_modes = config[CONF_SWING_HORIZONTAL_MODE_LIST] self._attr_swing_modes = config[CONF_SWING_MODE_LIST] self._attr_target_temperature_step = config[CONF_TEMP_STEP] @@ -568,6 +591,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_fan_mode = FAN_LOW + if ( + self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + or self._optimistic + ): + self._attr_swing_horizontal_mode = SWING_OFF if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_swing_mode = SWING_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: @@ -629,6 +657,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ): support |= ClimateEntityFeature.FAN_MODE + if (self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is not None) or ( + self._topic[CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC] is not None + ): + support |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or ( self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None ): @@ -744,6 +777,16 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ), {"_attr_fan_mode"}, ) + self.add_subscription( + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + partial( + self._handle_mode_received, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + "_attr_swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE_LIST, + ), + {"_attr_swing_horizontal_mode"}, + ) self.add_subscription( CONF_SWING_MODE_STATE_TOPIC, partial( @@ -782,6 +825,20 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self.async_write_ha_state() + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing horizontal mode.""" + payload = self._command_templates[CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE]( + swing_horizontal_mode + ) + await self._publish(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, payload) + + if ( + self._optimistic + or self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + ): + self._attr_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode) diff --git a/tests/components/climate/common.py b/tests/components/climate/common.py index d6aedd23671..8f5834d9180 100644 --- a/tests/components/climate/common.py +++ b/tests/components/climate/common.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -20,10 +21,11 @@ from homeassistant.components.climate import ( SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACMode, ) -from homeassistant.components.climate.const import HVACMode from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -211,6 +213,20 @@ def set_operation_mode( hass.services.call(DOMAIN, SERVICE_SET_HVAC_MODE, data) +async def async_set_swing_horizontal_mode( + hass: HomeAssistant, swing_horizontal_mode: str, entity_id: str = ENTITY_MATCH_ALL +) -> None: + """Set new target swing horizontal mode.""" + data = {ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call( + DOMAIN, SERVICE_SET_SWING_HORIZONTAL_MODE, data, blocking=True + ) + + async def async_set_swing_mode( hass: HomeAssistant, swing_mode: str, entity_id: str = ENTITY_MATCH_ALL ) -> None: diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 5edd73e3f5a..3760b0226f5 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_ACTION, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -85,6 +86,7 @@ DEFAULT_CONFIG = { "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", "swing_mode_command_topic": "swing-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ "eco", @@ -111,6 +113,7 @@ async def test_setup_params( assert state.attributes.get("temperature") == 21 assert state.attributes.get("fan_mode") == "low" assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" assert state.state == "off" assert state.attributes.get("min_temp") == DEFAULT_MIN_TEMP assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP @@ -123,6 +126,7 @@ async def test_setup_params( | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -159,6 +163,7 @@ async def test_supported_features( state = hass.states.get(ENTITY_CLIMATE) support = ( ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE @@ -562,12 +567,29 @@ async def test_set_swing_mode_bad_attr( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" + with pytest.raises(vol.Invalid) as excinfo: + await common.async_set_swing_horizontal_mode(hass, None, ENTITY_CLIMATE) # type:ignore[arg-type] + assert ( + "string value is None for dictionary value @ data['swing_horizontal_mode']" + in str(excinfo.value) + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize( "hass_config", [ help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},) + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + }, + ), ) ], ) @@ -579,19 +601,32 @@ async def test_set_swing_pessimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + assert state.attributes.get("swing_horizontal_mode") is None await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-state", "on") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "on") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + @pytest.mark.parametrize( "hass_config", @@ -599,7 +634,13 @@ async def test_set_swing_pessimistic( help_custom_config( climate.DOMAIN, DEFAULT_CONFIG, - ({"swing_mode_state_topic": "swing-state", "optimistic": True},), + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + "optimistic": True, + }, + ), ) ], ) @@ -611,19 +652,32 @@ async def test_set_swing_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "off") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "off") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( @@ -638,6 +692,15 @@ async def test_set_swing( mqtt_mock.async_publish.assert_called_once_with("swing-mode-topic", "on", 0, False) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + mqtt_mock.reset_mock() + + assert state.attributes.get("swing_horizontal_mode") == "off" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "on", 0, False + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -1337,6 +1400,7 @@ async def test_get_target_temperature_low_high_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1359,6 +1423,7 @@ async def test_get_target_temperature_low_high_with_templates( "action_topic": "action", "mode_state_topic": "mode-state", "fan_mode_state_topic": "fan-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", "swing_mode_state_topic": "swing-state", "temperature_state_topic": "temperature-state", "target_humidity_state_topic": "humidity-state", @@ -1396,6 +1461,12 @@ async def test_get_with_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + # Swing Horizontal Mode + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-horizontal-state", '"on"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Temperature - with valid value assert state.attributes.get("temperature") is None async_fire_mqtt_message(hass, "temperature-state", '"1031"') @@ -1495,6 +1566,7 @@ async def test_get_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1511,6 +1583,7 @@ async def test_get_with_templates( "power_command_template": "power: {{ value }}", "preset_mode_command_template": "preset_mode: {{ value }}", "mode_command_template": "mode: {{ value }}", + "swing_horizontal_mode_command_template": "swing_horizontal_mode: {{ value }}", "swing_mode_command_template": "swing_mode: {{ value }}", "temperature_command_template": "temp: {{ value }}", "temperature_high_command_template": "temp_hi: {{ value }}", @@ -1580,6 +1653,15 @@ async def test_set_and_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" + # Swing Horizontal Mode + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "swing_horizontal_mode: on", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Swing Mode await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) mqtt_mock.async_publish.assert_called_once_with( @@ -1940,6 +2022,7 @@ async def test_unique_id( ("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"), ("mode_state_topic", "cool", None, None), ("mode_state_topic", "fan_only", None, None), + ("swing_horizontal_mode_state_topic", "on", ATTR_SWING_HORIZONTAL_MODE, "on"), ("swing_mode_state_topic", "on", ATTR_SWING_MODE, "on"), ("temperature_low_state_topic", "19.1", ATTR_TARGET_TEMP_LOW, 19.1), ("temperature_high_state_topic", "22.9", ATTR_TARGET_TEMP_HIGH, 22.9), @@ -2178,6 +2261,13 @@ async def test_precision_whole( "medium", "fan_mode_command_template", ), + ( + climate.SERVICE_SET_SWING_HORIZONTAL_MODE, + "swing_horizontal_mode_command_topic", + {"swing_horizontal_mode": "on"}, + "on", + "swing_horizontal_mode_command_template", + ), ( climate.SERVICE_SET_SWING_MODE, "swing_mode_command_topic", @@ -2378,6 +2468,7 @@ async def test_unload_entry( "current_temperature_topic": "current-temperature-topic", "preset_mode_state_topic": "preset-mode-state-topic", "preset_modes": ["eco", "away"], + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", "swing_mode_state_topic": "swing-mode-state-topic", "target_humidity_state_topic": "target-humidity-state-topic", "temperature_high_state_topic": "temperature-high-state-topic", @@ -2399,6 +2490,7 @@ async def test_unload_entry( ("current-humidity-topic", "45", "46"), ("current-temperature-topic", "18.0", "18.1"), ("preset-mode-state-topic", "eco", "away"), + ("swing-horizontal-mode-state-topic", "on", "off"), ("swing-mode-state-topic", "on", "off"), ("target-humidity-state-topic", "45", "50"), ("temperature-state-topic", "18", "19"), diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 982167feee1..47c3a1e1988 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -2380,7 +2380,6 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_PRECISION", "CONF_QOS", "CONF_SCHEMA", - "CONF_SWING_MODE_LIST", "CONF_TEMP_STEP", # Removed "CONF_WHITE_VALUE", From 2826198d5d0655a6c890afcaa08f70f8e8abe60b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:48:51 +0100 Subject: [PATCH 1291/1435] Add entity translations to SmartThings (#139342) * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * fix * fix * Add AC tests * Add thermostat tests * Add cover tests * Add device tests * Add light tests * Add rest of the tests * Add oauth * Add oauth tests * Add oauth tests * Add oauth tests * Add oauth tests * Bump version * Add rest of the tests * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Iterate over entities instead * use set * use const * uncomment * fix handler * Fix device info * Fix device info * Fix lib * Fix lib * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Add fake fan * Fix * Add entity translations to SmartThings * Fix --- .../components/smartthings/binary_sensor.py | 6 +- .../components/smartthings/climate.py | 3 + homeassistant/components/smartthings/cover.py | 1 + .../components/smartthings/entity.py | 2 +- homeassistant/components/smartthings/fan.py | 1 + homeassistant/components/smartthings/light.py | 1 + homeassistant/components/smartthings/lock.py | 2 + .../components/smartthings/sensor.py | 134 +- .../components/smartthings/strings.json | 183 ++ .../components/smartthings/switch.py | 2 + .../snapshots/test_binary_sensor.ambr | 102 +- .../smartthings/snapshots/test_climate.ambr | 16 +- .../smartthings/snapshots/test_cover.ambr | 8 +- .../smartthings/snapshots/test_fan.ambr | 4 +- .../smartthings/snapshots/test_light.ambr | 16 +- .../smartthings/snapshots/test_lock.ambr | 4 +- .../smartthings/snapshots/test_sensor.ambr | 2320 ++++++++--------- .../smartthings/snapshots/test_switch.ambr | 40 +- .../smartthings/test_binary_sensor.py | 4 +- tests/components/smartthings/test_sensor.py | 9 +- 20 files changed, 1517 insertions(+), 1341 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 6afa4edcf17..99cbd3f9353 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -33,6 +33,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.ACCELERATION_SENSOR: { Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription( key=Attribute.ACCELERATION, + translation_key="acceleration", device_class=BinarySensorDeviceClass.MOVING, is_on_key="active", ) @@ -47,6 +48,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.FILTER_STATUS: { Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( key=Attribute.FILTER_STATUS, + translation_key="filter_status", device_class=BinarySensorDeviceClass.PROBLEM, is_on_key="replace", ) @@ -75,7 +77,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.TAMPER_ALERT: { Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( key=Attribute.TAMPER, - device_class=BinarySensorDeviceClass.PROBLEM, + device_class=BinarySensorDeviceClass.TAMPER, is_on_key="detected", entity_category=EntityCategory.DIAGNOSTIC, ) @@ -83,6 +85,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.VALVE: { Attribute.VALVE: SmartThingsBinarySensorEntityDescription( key=Attribute.VALVE, + translation_key="valve", device_class=BinarySensorDeviceClass.OPENING, is_on_key="open", ) @@ -133,7 +136,6 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self._attribute = attribute self.capability = capability self.entity_description = entity_description - self._attr_name = f"{device.device.label} {attribute}" self._attr_unique_id = f"{device.device.device_id}.{attribute}" @property diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2e05fb2fc4f..2c3b8f3ac03 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -135,6 +135,8 @@ async def async_setup_entry( class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" + _attr_name = None + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( @@ -322,6 +324,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" + _attr_name = None _attr_preset_mode = None def __init__(self, client: SmartThings, device: FullDevice) -> None: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 97a7456d132..fd4752b4e28 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -51,6 +51,7 @@ async def async_setup_entry( class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" + _attr_name = None _state: CoverState | None = None def __init__( diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index f5f1f268801..b2e556c6718 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -17,6 +17,7 @@ class SmartThingsEntity(Entity): """Defines a SmartThings entity.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, client: SmartThings, device: FullDevice, capabilities: set[Capability] @@ -30,7 +31,6 @@ class SmartThingsEntity(Entity): if capability in device.status[MAIN] } self.device = device - self._attr_name = device.device.label self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( configuration_url="https://account.smartthings.com", diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 23afb0baeb2..8edf01ec613 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -48,6 +48,7 @@ async def async_setup_entry( class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" + _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) def __init__(self, client: SmartThings, device: FullDevice) -> None: diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 582f9dd5435..54e8ad18a7c 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -56,6 +56,7 @@ def convert_scale( class SmartThingsLight(SmartThingsEntity, LightEntity): """Define a SmartThings Light.""" + _attr_name = None _attr_supported_color_modes: set[ColorMode] # SmartThings does not expose this attribute, instead it's diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 56274dfe161..f56ecd5d565 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -42,6 +42,8 @@ async def async_setup_entry( class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" + _attr_name = None + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" await self.execute_device_command( diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b16d332a1ae..6685d6be726 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -69,7 +69,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.LIGHTING_MODE, - name="Activity Lighting Mode", + translation_key="lighting_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -78,7 +78,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.AIR_CONDITIONER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.AIR_CONDITIONER_MODE, - name="Air Conditioner Mode", + translation_key="air_conditioner_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[ { @@ -93,7 +93,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.AIR_QUALITY: [ SmartThingsSensorEntityDescription( key=Attribute.AIR_QUALITY, - name="Air Quality", + translation_key="air_quality", native_unit_of_measurement="CAQI", state_class=SensorStateClass.MEASUREMENT, ) @@ -103,7 +103,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ALARM: [ SmartThingsSensorEntityDescription( key=Attribute.ALARM, - name="Alarm", + translation_key="alarm", ) ] }, @@ -111,7 +111,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.VOLUME: [ SmartThingsSensorEntityDescription( key=Attribute.VOLUME, - name="Volume", + translation_key="audio_volume", native_unit_of_measurement=PERCENTAGE, ) ] @@ -120,7 +120,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BATTERY: [ SmartThingsSensorEntityDescription( key=Attribute.BATTERY, - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +131,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.BMI_MEASUREMENT, - name="Body Mass Index", + translation_key="body_mass_index", native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", state_class=SensorStateClass.MEASUREMENT, ) @@ -143,7 +142,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.BODY_WEIGHT_MEASUREMENT, - name="Body Weight", + translation_key="body_weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -155,7 +154,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_DIOXIDE, - name="Carbon Dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -167,7 +165,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE, - name="Carbon Monoxide Detector", + translation_key="carbon_monoxide_detector", ) ] }, @@ -176,7 +174,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE_LEVEL, - name="Carbon Monoxide Level", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, @@ -187,19 +184,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Dishwasher Machine State", + translation_key="dishwasher_machine_state", ) ], Attribute.DISHWASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.DISHWASHER_JOB_STATE, - name="Dishwasher Job State", + translation_key="dishwasher_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Dishwasher Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -210,7 +207,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.DRYER_MODE, - name="Dryer Mode", + translation_key="dryer_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -219,19 +216,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Dryer Machine State", + translation_key="dryer_machine_state", ) ], Attribute.DRYER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.DRYER_JOB_STATE, - name="Dryer Job State", + translation_key="dryer_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Dryer Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -241,14 +238,14 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.DUST_LEVEL, - name="Dust Level", + translation_key="dust_level", state_class=SensorStateClass.MEASUREMENT, ) ], Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FINE_DUST_LEVEL, - name="Fine Dust Level", + translation_key="fine_dust_level", state_class=SensorStateClass.MEASUREMENT, ) ], @@ -257,7 +254,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ENERGY: [ SmartThingsSensorEntityDescription( key=Attribute.ENERGY, - name="Energy Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -269,7 +265,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, - name="Equivalent Carbon Dioxide Measurement", + translation_key="equivalent_carbon_dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -281,7 +277,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FORMALDEHYDE_LEVEL, - name="Formaldehyde Measurement", + translation_key="formaldehyde", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) @@ -292,7 +288,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER, - name="Gas Meter", + translation_key="gas_meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.MEASUREMENT, @@ -301,13 +297,13 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER_CALORIFIC: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_CALORIFIC, - name="Gas Meter Calorific", + translation_key="gas_meter_calorific", ) ], Attribute.GAS_METER_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_TIME, - name="Gas Meter Time", + translation_key="gas_meter_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -315,7 +311,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER_VOLUME: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_VOLUME, - name="Gas Meter Volume", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.MEASUREMENT, @@ -327,7 +322,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( key=Attribute.ILLUMINANCE, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, @@ -339,7 +333,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.INFRARED_LEVEL, - name="Infrared Level", + translation_key="infrared_level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ) @@ -349,7 +343,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.INPUT_SOURCE: [ SmartThingsSensorEntityDescription( key=Attribute.INPUT_SOURCE, - name="Media Input Source", + translation_key="media_input_source", ) ] }, @@ -358,7 +352,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_REPEAT_MODE, - name="Media Playback Repeat", + translation_key="media_playback_repeat", ) ] }, @@ -367,7 +361,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_SHUFFLE, - name="Media Playback Shuffle", + translation_key="media_playback_shuffle", ) ] }, @@ -375,7 +369,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_STATUS: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_STATUS, - name="Media Playback Status", + translation_key="media_playback_status", ) ] }, @@ -383,7 +377,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ODOR_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.ODOR_LEVEL, - name="Odor Sensor", + translation_key="odor_sensor", ) ] }, @@ -391,7 +385,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.OVEN_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_MODE, - name="Oven Mode", + translation_key="oven_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -400,19 +394,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Oven Machine State", + translation_key="oven_machine_state", ) ], Attribute.OVEN_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_JOB_STATE, - name="Oven Job State", + translation_key="oven_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Oven Completion Time", + translation_key="completion_time", ) ], }, @@ -420,7 +414,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.OVEN_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_SETPOINT, - name="Oven Set Point", + translation_key="oven_setpoint", ) ] }, @@ -428,7 +422,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER_CONSUMPTION: [ SmartThingsSensorEntityDescription( key="energy_meter", - name="energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -436,7 +429,6 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="power_meter", - name="power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -445,7 +437,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", - name="deltaEnergy", + translation_key="energy_difference", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -453,7 +445,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", - name="powerEnergy", + translation_key="power_energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -461,7 +453,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="energySaved_meter", - name="energySaved", + translation_key="energy_saved", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -473,7 +465,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER: [ SmartThingsSensorEntityDescription( key=Attribute.POWER, - name="Power Meter", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -485,7 +476,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( key=Attribute.POWER_SOURCE, - name="Power Source", + translation_key="power_source", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -495,7 +486,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.REFRIGERATION_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.REFRIGERATION_SETPOINT, - name="Refrigeration Setpoint", + translation_key="refrigeration_setpoint", device_class=SensorDeviceClass.TEMPERATURE, ) ] @@ -504,7 +495,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( key=Attribute.HUMIDITY, - name="Relative Humidity Measurement", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -515,7 +505,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_CLEANING_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_CLEANING_MODE, - name="Robot Cleaner Cleaning Mode", + translation_key="robot_cleaner_cleaning_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ], @@ -524,7 +514,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_MOVEMENT: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_MOVEMENT, - name="Robot Cleaner Movement", + translation_key="robot_cleaner_movement", ) ] }, @@ -532,7 +522,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_TURBO_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_TURBO_MODE, - name="Robot Cleaner Turbo Mode", + translation_key="robot_cleaner_turbo_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -542,7 +532,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.LQI: [ SmartThingsSensorEntityDescription( key=Attribute.LQI, - name="LQI Signal Strength", + translation_key="link_quality", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -550,7 +540,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.RSSI: [ SmartThingsSensorEntityDescription( key=Attribute.RSSI, - name="RSSI Signal Strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -562,7 +551,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.SMOKE: [ SmartThingsSensorEntityDescription( key=Attribute.SMOKE, - name="Smoke Detector", + translation_key="smoke_detector", ) ] }, @@ -570,7 +559,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TEMPERATURE: [ SmartThingsSensorEntityDescription( key=Attribute.TEMPERATURE, - name="Temperature Measurement", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ) @@ -580,7 +568,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.COOLING_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.COOLING_SETPOINT, - name="Thermostat Cooling Setpoint", + translation_key="thermostat_cooling_setpoint", device_class=SensorDeviceClass.TEMPERATURE, capability_ignore_list=[ { @@ -598,7 +586,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_FAN_MODE, - name="Thermostat Fan Mode", + translation_key="thermostat_fan_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) @@ -609,7 +597,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.HEATING_SETPOINT, - name="Thermostat Heating Setpoint", + translation_key="thermostat_heating_setpoint", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], @@ -621,7 +609,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_MODE, - name="Thermostat Mode", + translation_key="thermostat_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) @@ -632,7 +620,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_OPERATING_STATE, - name="Thermostat Operating State", + translation_key="thermostat_operating_state", capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] @@ -642,7 +630,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_SETPOINT, - name="Thermostat Setpoint", + translation_key="thermostat_setpoint", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -652,19 +640,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( key="X Coordinate", - name="X Coordinate", + translation_key="x_coordinate", unique_id_separator=" ", value_fn=lambda value: value[0], ), SmartThingsSensorEntityDescription( key="Y Coordinate", - name="Y Coordinate", + translation_key="y_coordinate", unique_id_separator=" ", value_fn=lambda value: value[1], ), SmartThingsSensorEntityDescription( key="Z Coordinate", - name="Z Coordinate", + translation_key="z_coordinate", unique_id_separator=" ", value_fn=lambda value: value[2], ), @@ -674,13 +662,13 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TV_CHANNEL: [ SmartThingsSensorEntityDescription( key=Attribute.TV_CHANNEL, - name="Tv Channel", + translation_key="tv_channel", ) ], Attribute.TV_CHANNEL_NAME: [ SmartThingsSensorEntityDescription( key=Attribute.TV_CHANNEL_NAME, - name="Tv Channel Name", + translation_key="tv_channel_name", ) ], }, @@ -689,7 +677,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.TVOC_LEVEL, - name="Tvoc Measurement", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) @@ -700,7 +688,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( key=Attribute.ULTRAVIOLET_INDEX, - name="Ultraviolet Index", + translation_key="uv_index", state_class=SensorStateClass.MEASUREMENT, ) ] @@ -709,7 +697,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( key=Attribute.VOLTAGE, - name="Voltage Measurement", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ) @@ -720,7 +707,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.WASHER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.WASHER_MODE, - name="Washer Mode", + translation_key="washer_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -729,19 +716,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Washer Machine State", + translation_key="washer_machine_state", ) ], Attribute.WASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.WASHER_JOB_STATE, - name="Washer Job State", + translation_key="washer_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Washer Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -795,7 +782,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): ) -> None: """Init the class.""" super().__init__(client, device, {capability}) - self._attr_name = f"{device.device.label} {entity_description.name}" self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 5112d819026..9cfc6176d20 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -25,5 +25,188 @@ "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." } + }, + "entity": { + "binary_sensor": { + "acceleration": { + "name": "Acceleration" + }, + "filter_status": { + "name": "Filter status" + }, + "valve": { + "name": "Valve" + } + }, + "sensor": { + "lighting_mode": { + "name": "Activity lighting mode" + }, + "air_conditioner_mode": { + "name": "Air conditioner mode" + }, + "air_quality": { + "name": "Air quality" + }, + "alarm": { + "name": "Alarm" + }, + "audio_volume": { + "name": "Volume" + }, + "body_mass_index": { + "name": "Body mass index" + }, + "body_weight": { + "name": "Body weight" + }, + "carbon_monoxide_detector": { + "name": "Carbon monoxide detector" + }, + "dishwasher_machine_state": { + "name": "Machine state" + }, + "dishwasher_job_state": { + "name": "Job state" + }, + "completion_time": { + "name": "Completion time" + }, + "dryer_mode": { + "name": "Dryer mode" + }, + "dryer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "dryer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + }, + "dust_level": { + "name": "Dust level" + }, + "fine_dust_level": { + "name": "Fine dust level" + }, + "equivalent_carbon_dioxide": { + "name": "Equivalent carbon dioxide" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "gas_meter": { + "name": "Gas meter" + }, + "gas_meter_calorific": { + "name": "Gas meter calorific" + }, + "gas_meter_time": { + "name": "Gas meter time" + }, + "infrared_level": { + "name": "Infrared level" + }, + "media_input_source": { + "name": "Media input source" + }, + "media_playback_repeat": { + "name": "Media playback repeat" + }, + "media_playback_shuffle": { + "name": "Media playback shuffle" + }, + "media_playback_status": { + "name": "Media playback status" + }, + "odor_sensor": { + "name": "Odor sensor" + }, + "oven_mode": { + "name": "Oven mode" + }, + "oven_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "oven_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + }, + "oven_setpoint": { + "name": "Set point" + }, + "energy_difference": { + "name": "Energy difference" + }, + "power_energy": { + "name": "Power energy" + }, + "energy_saved": { + "name": "Energy saved" + }, + "power_source": { + "name": "Power source" + }, + "refrigeration_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "robot_cleaner_cleaning_mode": { + "name": "Cleaning mode" + }, + "robot_cleaner_movement": { + "name": "Movement" + }, + "robot_cleaner_turbo_mode": { + "name": "Turbo mode" + }, + "link_quality": { + "name": "Link quality" + }, + "smoke_detector": { + "name": "Smoke detector" + }, + "thermostat_cooling_setpoint": { + "name": "Cooling set point" + }, + "thermostat_fan_mode": { + "name": "Fan mode" + }, + "thermostat_heating_setpoint": { + "name": "Heating set point" + }, + "thermostat_mode": { + "name": "Mode" + }, + "thermostat_operating_state": { + "name": "Operating state" + }, + "thermostat_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "x_coordinate": { + "name": "X coordinate" + }, + "y_coordinate": { + "name": "Y coordinate" + }, + "z_coordinate": { + "name": "Z coordinate" + }, + "tv_channel": { + "name": "TV channel" + }, + "tv_channel_name": { + "name": "TV channel name" + }, + "uv_index": { + "name": "UV index" + }, + "washer_mode": { + "name": "Washer mode" + }, + "washer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "washer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + } + } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index d8cd9f1f956..380005f1b93 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -48,6 +48,8 @@ async def async_setup_entry( class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" + _attr_name = None + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.execute_device_command( diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 1317c19edd7..27a5e38a123 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -13,7 +13,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway motion', + 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -37,7 +37,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'motion', - 'friendly_name': '2nd Floor Hallway motion', + 'friendly_name': '2nd Floor Hallway Motion', }), 'context': , 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', @@ -61,7 +61,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway sound', + 'original_name': 'Sound', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -85,7 +85,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'sound', - 'friendly_name': '2nd Floor Hallway sound', + 'friendly_name': '2nd Floor Hallway Sound', }), 'context': , 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', @@ -95,7 +95,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-entry] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,8 +108,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -129,21 +129,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-state] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': '.Front Door Open/Closed Sensor contact', + 'friendly_name': '.Front Door Open/Closed Sensor Door', }), 'context': , - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-entry] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -156,8 +156,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.refrigerator_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.refrigerator_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -168,7 +168,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -177,14 +177,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-state] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Refrigerator contact', + 'friendly_name': 'Refrigerator Door', }), 'context': , - 'entity_id': 'binary_sensor.refrigerator_contact', + 'entity_id': 'binary_sensor.refrigerator_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -205,7 +205,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.child_bedroom_motion', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -216,7 +216,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom motion', + 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -229,7 +229,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'motion', - 'friendly_name': 'Child Bedroom motion', + 'friendly_name': 'Child Bedroom Motion', }), 'context': , 'entity_id': 'binary_sensor.child_bedroom_motion', @@ -253,7 +253,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.child_bedroom_presence', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -264,7 +264,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom presence', + 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -277,7 +277,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'presence', - 'friendly_name': 'Child Bedroom presence', + 'friendly_name': 'Child Bedroom Presence', }), 'context': , 'entity_id': 'binary_sensor.child_bedroom_presence', @@ -301,7 +301,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.iphone_presence', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -312,7 +312,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'iPhone presence', + 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -325,7 +325,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'presence', - 'friendly_name': 'iPhone presence', + 'friendly_name': 'iPhone Presence', }), 'context': , 'entity_id': 'binary_sensor.iphone_presence', @@ -349,7 +349,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.deck_door_acceleration', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -360,11 +360,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door acceleration', + 'original_name': 'Acceleration', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'acceleration', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', 'unit_of_measurement': None, }) @@ -373,7 +373,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'moving', - 'friendly_name': 'Deck Door acceleration', + 'friendly_name': 'Deck Door Acceleration', }), 'context': , 'entity_id': 'binary_sensor.deck_door_acceleration', @@ -383,7 +383,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-entry] +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -396,8 +396,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.deck_door_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.deck_door_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -408,7 +408,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -417,14 +417,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-state] +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Deck Door contact', + 'friendly_name': 'Deck Door Door', }), 'context': , - 'entity_id': 'binary_sensor.deck_door_contact', + 'entity_id': 'binary_sensor.deck_door_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -445,7 +445,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.volvo_valve', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -456,11 +456,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'volvo valve', + 'original_name': 'Valve', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'valve', 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', 'unit_of_measurement': None, }) @@ -469,7 +469,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'opening', - 'friendly_name': 'volvo valve', + 'friendly_name': 'volvo Valve', }), 'context': , 'entity_id': 'binary_sensor.volvo_valve', @@ -479,7 +479,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-entry] +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -492,8 +492,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.asd_water', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.asd_moisture', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -504,7 +504,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd water', + 'original_name': 'Moisture', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -513,14 +513,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-state] +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', - 'friendly_name': 'asd water', + 'friendly_name': 'asd Moisture', }), 'context': , - 'entity_id': 'binary_sensor.asd_water', + 'entity_id': 'binary_sensor.asd_moisture', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index bd76637cfb7..ba32776011a 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -35,7 +35,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.ac_office_granit', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -46,7 +46,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -140,7 +140,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.aire_dormitorio_principal', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -151,7 +151,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -234,7 +234,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.main_floor', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -245,7 +245,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Main Floor', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -307,7 +307,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.asd', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -318,7 +318,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'asd', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 6283e4fef04..102be416cea 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -13,7 +13,7 @@ 'domain': 'cover', 'entity_category': None, 'entity_id': 'cover.curtain_1a', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Curtain 1A', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -63,7 +63,7 @@ 'domain': 'cover', 'entity_category': None, 'entity_id': 'cover.microwave', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -74,7 +74,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Microwave', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 400ceef8390..33caffcacc6 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -21,7 +21,7 @@ 'domain': 'fan', 'entity_category': None, 'entity_id': 'fan.fake_fan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,7 +32,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fake fan', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 8e7f424f658..8766811c443 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -17,7 +17,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.dimmer_debian', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -28,7 +28,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dimmer Debian', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -74,7 +74,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.basement_exit_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -85,7 +85,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Basement Exit Light', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -135,7 +135,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.bathroom_spot', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -146,7 +146,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Bathroom spot', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -216,7 +216,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.standing_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -227,7 +227,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Standing light', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr index 94370f8570b..2cf9688c3dd 100644 --- a/tests/components/smartthings/snapshots/test_lock.ambr +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -13,7 +13,7 @@ 'domain': 'lock', 'entity_category': None, 'entity_id': 'lock.basement_door_lock', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Basement Door Lock', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 92928b9606b..2fca1a8d108 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,8 +14,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -26,7 +26,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Energy Meter', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -35,23 +35,23 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aeotec Energy Monitor Energy Meter', + 'friendly_name': 'Aeotec Energy Monitor Energy', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '19978.536', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -66,8 +66,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -78,7 +78,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -87,23 +87,23 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aeotec Energy Monitor Power Meter', + 'friendly_name': 'Aeotec Energy Monitor Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'entity_id': 'sensor.aeotec_energy_monitor_power', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2859.743', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -118,8 +118,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -130,7 +130,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Voltage Measurement', + 'original_name': 'Voltage', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -139,22 +139,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Aeotec Energy Monitor Voltage Measurement', + 'friendly_name': 'Aeotec Energy Monitor Voltage', 'state_class': , }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-entry] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -169,8 +169,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeon_energy_monitor_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -181,7 +181,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeon Energy Monitor Energy Meter', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -190,23 +190,23 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-state] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aeon Energy Monitor Energy Meter', + 'friendly_name': 'Aeon Energy Monitor Energy', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'entity_id': 'sensor.aeon_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1930.362', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-entry] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -221,8 +221,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeon_energy_monitor_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeon_energy_monitor_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -233,7 +233,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeon Energy Monitor Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -242,16 +242,16 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-state] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aeon Energy Monitor Power Meter', + 'friendly_name': 'Aeon Energy Monitor Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'entity_id': 'sensor.aeon_energy_monitor_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -272,7 +272,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.2nd_floor_hallway_alarm', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -283,11 +283,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '2nd Floor Hallway Alarm', + 'original_name': 'Alarm', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'alarm', 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', 'unit_of_measurement': None, }) @@ -319,7 +319,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.2nd_floor_hallway_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -330,7 +330,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -354,7 +354,7 @@ 'state': '100', }) # --- -# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-entry] +# name: test_all_entities[centralite][sensor.dimmer_debian_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -369,8 +369,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dimmer_debian_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.dimmer_debian_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -381,7 +381,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dimmer Debian Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -390,16 +390,16 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-state] +# name: test_all_entities[centralite][sensor.dimmer_debian_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dimmer Debian Power Meter', + 'friendly_name': 'Dimmer Debian Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.dimmer_debian_power_meter', + 'entity_id': 'sensor.dimmer_debian_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -420,7 +420,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.front_door_open_closed_sensor_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -431,7 +431,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -455,7 +455,7 @@ 'state': '100', }) # --- -# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-entry] +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -470,8 +470,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -482,7 +482,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -491,16 +491,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-state] +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'friendly_name': '.Front Door Open/Closed Sensor Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -523,7 +523,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -534,11 +534,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Air Quality', + 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', 'unit_of_measurement': 'CAQI', }) @@ -546,7 +546,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Air Quality', + 'friendly_name': 'AC Office Granit Air quality', 'state_class': , 'unit_of_measurement': 'CAQI', }), @@ -558,58 +558,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'AC Office Granit deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.4', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -626,7 +574,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -637,11 +585,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Dust Level', + 'original_name': 'Dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'dust_level', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', 'unit_of_measurement': 'μg/m^3', }) @@ -649,7 +597,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Dust Level', + 'friendly_name': 'AC Office Granit Dust level', 'state_class': , 'unit_of_measurement': 'μg/m^3', }), @@ -677,7 +625,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -688,7 +636,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -701,7 +649,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit energy', + 'friendly_name': 'AC Office Granit Energy', 'state_class': , 'unit_of_measurement': , }), @@ -713,7 +661,7 @@ 'state': '2247.3', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -728,8 +676,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -740,25 +688,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit energySaved', + 'friendly_name': 'AC Office Granit Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_energysaved', + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -781,7 +781,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -792,11 +792,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Fine Dust Level', + 'original_name': 'Fine dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fine_dust_level', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', 'unit_of_measurement': 'μg/m^3', }) @@ -804,7 +804,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Fine Dust Level', + 'friendly_name': 'AC Office Granit Fine dust level', 'state_class': , 'unit_of_measurement': 'μg/m^3', }), @@ -816,6 +816,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -832,7 +884,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -843,7 +895,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -856,7 +908,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'AC Office Granit power', + 'friendly_name': 'AC Office Granit Power', 'power_consumption_end': '2025-02-09T16:15:33Z', 'power_consumption_start': '2025-02-09T15:45:29Z', 'state_class': , @@ -870,7 +922,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -885,8 +937,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -897,32 +949,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit powerEnergy', + 'friendly_name': 'AC Office Granit Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'entity_id': 'sensor.ac_office_granit_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -937,60 +989,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'AC Office Granit Relative Humidity Measurement', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'AC Office Granit Relative Humidity Measurement', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1001,7 +1001,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1010,16 +1010,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'AC Office Granit Temperature Measurement', + 'friendly_name': 'AC Office Granit Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'entity_id': 'sensor.ac_office_granit_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1040,7 +1040,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1051,11 +1051,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', 'unit_of_measurement': '%', }) @@ -1090,7 +1090,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1101,11 +1101,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Air Quality', + 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', 'unit_of_measurement': 'CAQI', }) @@ -1113,7 +1113,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Air Quality', + 'friendly_name': 'Aire Dormitorio Principal Air quality', 'state_class': , 'unit_of_measurement': 'CAQI', }), @@ -1125,58 +1125,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1193,7 +1141,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1204,11 +1152,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Dust Level', + 'original_name': 'Dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'dust_level', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', 'unit_of_measurement': None, }) @@ -1216,7 +1164,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Dust Level', + 'friendly_name': 'Aire Dormitorio Principal Dust level', 'state_class': , }), 'context': , @@ -1243,7 +1191,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1254,7 +1202,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1267,7 +1215,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal energy', + 'friendly_name': 'Aire Dormitorio Principal Energy', 'state_class': , 'unit_of_measurement': , }), @@ -1279,7 +1227,7 @@ 'state': '13.836', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1294,8 +1242,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1306,25 +1254,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal energySaved', + 'friendly_name': 'Aire Dormitorio Principal Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1347,7 +1347,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1358,11 +1358,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Fine Dust Level', + 'original_name': 'Fine dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fine_dust_level', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', 'unit_of_measurement': None, }) @@ -1370,7 +1370,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Fine Dust Level', + 'friendly_name': 'Aire Dormitorio Principal Fine dust level', 'state_class': , }), 'context': , @@ -1381,6 +1381,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Aire Dormitorio Principal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1395,7 +1447,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1406,11 +1458,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Odor Sensor', + 'original_name': 'Odor sensor', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'odor_sensor', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', 'unit_of_measurement': None, }) @@ -1418,7 +1470,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Odor Sensor', + 'friendly_name': 'Aire Dormitorio Principal Odor sensor', }), 'context': , 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', @@ -1444,7 +1496,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1455,7 +1507,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1468,7 +1520,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aire Dormitorio Principal power', + 'friendly_name': 'Aire Dormitorio Principal Power', 'power_consumption_end': '2025-02-09T17:02:44Z', 'power_consumption_start': '2025-02-09T16:08:15Z', 'state_class': , @@ -1482,7 +1534,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1497,8 +1549,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1509,32 +1561,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal powerEnergy', + 'friendly_name': 'Aire Dormitorio Principal Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1549,60 +1601,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Relative Humidity Measurement', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Aire Dormitorio Principal Relative Humidity Measurement', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1613,7 +1613,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1622,16 +1622,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Aire Dormitorio Principal Temperature Measurement', + 'friendly_name': 'Aire Dormitorio Principal Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1652,7 +1652,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1663,11 +1663,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', 'unit_of_measurement': '%', }) @@ -1686,7 +1686,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1699,8 +1699,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1711,29 +1711,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Completion Time', + 'friendly_name': 'Microwave Completion time', }), 'context': , - 'entity_id': 'sensor.microwave_oven_completion_time', + 'entity_id': 'sensor.microwave_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T21:13:36.184Z', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1746,8 +1746,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_job_state', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_job_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1758,29 +1758,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Job State', + 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_job_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Job State', + 'friendly_name': 'Microwave Job state', }), 'context': , - 'entity_id': 'sensor.microwave_oven_job_state', + 'entity_id': 'sensor.microwave_job_state', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'ready', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1793,8 +1793,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_machine_state', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_machine_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1805,22 +1805,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Machine State', + 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_machine_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Machine State', + 'friendly_name': 'Microwave Machine state', }), 'context': , - 'entity_id': 'sensor.microwave_oven_machine_state', + 'entity_id': 'sensor.microwave_machine_state', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1841,7 +1841,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.microwave_oven_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1852,11 +1852,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Mode', + 'original_name': 'Oven mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_mode', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', 'unit_of_measurement': None, }) @@ -1864,7 +1864,7 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Mode', + 'friendly_name': 'Microwave Oven mode', }), 'context': , 'entity_id': 'sensor.microwave_oven_mode', @@ -1874,7 +1874,7 @@ 'state': 'Others', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1887,8 +1887,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_set_point', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1899,29 +1899,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Set Point', + 'original_name': 'Set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_setpoint', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Set Point', + 'friendly_name': 'Microwave Set point', }), 'context': , - 'entity_id': 'sensor.microwave_oven_set_point', + 'entity_id': 'sensor.microwave_set_point', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1936,8 +1936,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1948,7 +1948,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Microwave Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1957,30 +1957,28 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Microwave Temperature Measurement', + 'friendly_name': 'Microwave Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.microwave_temperature_measurement', + 'entity_id': 'sensor.microwave_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '-17', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1988,8 +1986,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_deltaenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_cooling_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1998,31 +1996,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator deltaEnergy', + 'original_name': 'Cooling set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', - 'unit_of_measurement': , + 'translation_key': 'thermostat_cooling_setpoint', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Refrigerator deltaEnergy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Cooling set point', }), 'context': , - 'entity_id': 'sensor.refrigerator_deltaenergy', + 'entity_id': 'sensor.refrigerator_cooling_set_point', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.007', + 'state': 'unknown', }) # --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] @@ -2041,7 +2037,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.refrigerator_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2052,7 +2048,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2065,7 +2061,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator energy', + 'friendly_name': 'Refrigerator Energy', 'state_class': , 'unit_of_measurement': , }), @@ -2077,7 +2073,7 @@ 'state': '1568.087', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2092,8 +2088,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2104,25 +2100,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator energySaved', + 'friendly_name': 'Refrigerator Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.refrigerator_energysaved', + 'entity_id': 'sensor.refrigerator_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2145,7 +2193,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.refrigerator_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2156,7 +2204,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2169,7 +2217,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Refrigerator power', + 'friendly_name': 'Refrigerator Power', 'power_consumption_end': '2025-02-09T17:49:00Z', 'power_consumption_start': '2025-02-09T17:38:01Z', 'state_class': , @@ -2183,7 +2231,7 @@ 'state': '6', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2198,8 +2246,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2210,32 +2258,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator powerEnergy', + 'friendly_name': 'Refrigerator Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.refrigerator_powerenergy', + 'entity_id': 'sensor.refrigerator_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0135559777781698', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2250,8 +2298,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2262,7 +2310,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2271,63 +2319,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Temperature Measurement', + 'friendly_name': 'Refrigerator Temperature', 'state_class': , }), 'context': , - 'entity_id': 'sensor.refrigerator_temperature_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Refrigerator Thermostat Cooling Setpoint', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Thermostat Cooling Setpoint', - }), - 'context': , - 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'entity_id': 'sensor.refrigerator_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2348,7 +2348,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.robot_vacuum_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2359,7 +2359,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Robot vacuum Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2383,7 +2383,7 @@ 'state': '100', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2396,8 +2396,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2408,29 +2408,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'original_name': 'Cleaning mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_cleaning_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'friendly_name': 'Robot vacuum Cleaning mode', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'stop', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2443,8 +2443,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_movement', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2455,29 +2455,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Movement', + 'original_name': 'Movement', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_movement', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Movement', + 'friendly_name': 'Robot vacuum Movement', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'entity_id': 'sensor.robot_vacuum_movement', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'idle', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2490,8 +2490,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2502,81 +2502,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'original_name': 'Turbo mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_turbo_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'friendly_name': 'Robot vacuum Turbo mode', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'entity_id': 'sensor.robot_vacuum_turbo_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dishwasher_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dishwasher deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Dishwasher deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dishwasher_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2589,8 +2537,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2601,123 +2549,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Dishwasher Dishwasher Completion Time', + 'friendly_name': 'Dishwasher Completion time', }), 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'entity_id': 'sensor.dishwasher_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T22:49:26+00:00', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dishwasher_dishwasher_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Dishwasher Job State', - }), - 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Dishwasher Machine State', - }), - 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2734,7 +2588,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dishwasher_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2745,7 +2599,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2758,7 +2612,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher energy', + 'friendly_name': 'Dishwasher Energy', 'state_class': , 'unit_of_measurement': , }), @@ -2770,7 +2624,7 @@ 'state': '101.6', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2785,8 +2639,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2797,31 +2651,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher energySaved', + 'friendly_name': 'Dishwasher Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_energysaved', + 'entity_id': 'sensor.dishwasher_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_job_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Job state', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_machine_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Machine state', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2838,7 +2838,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dishwasher_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2849,7 +2849,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2862,7 +2862,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dishwasher power', + 'friendly_name': 'Dishwasher Power', 'power_consumption_end': '2025-02-08T20:21:26Z', 'power_consumption_start': '2025-02-08T20:21:21Z', 'state_class': , @@ -2876,7 +2876,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2891,8 +2891,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2903,84 +2903,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher powerEnergy', + 'friendly_name': 'Dishwasher Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_powerenergy', + 'entity_id': 'sensor.dishwasher_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dryer_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dryer deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Dryer deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dryer_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2993,8 +2941,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_dryer_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3005,123 +2953,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer Dryer Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Dryer Dryer Completion Time', + 'friendly_name': 'Dryer Completion time', }), 'context': , - 'entity_id': 'sensor.dryer_dryer_completion_time', + 'entity_id': 'sensor.dryer_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T19:25:10+00:00', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dryer_dryer_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dryer Dryer Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer Dryer Job State', - }), - 'context': , - 'entity_id': 'sensor.dryer_dryer_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dryer_dryer_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dryer Dryer Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer Dryer Machine State', - }), - 'context': , - 'entity_id': 'sensor.dryer_dryer_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3138,7 +2992,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dryer_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3149,7 +3003,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3162,7 +3016,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer energy', + 'friendly_name': 'Dryer Energy', 'state_class': , 'unit_of_measurement': , }), @@ -3174,7 +3028,7 @@ 'state': '4495.5', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3189,8 +3043,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3201,31 +3055,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer energySaved', + 'friendly_name': 'Dryer Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dryer_energysaved', + 'entity_id': 'sensor.dryer_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Job state', + }), + 'context': , + 'entity_id': 'sensor.dryer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Machine state', + }), + 'context': , + 'entity_id': 'sensor.dryer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3242,7 +3242,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dryer_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3253,7 +3253,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3266,7 +3266,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dryer power', + 'friendly_name': 'Dryer Power', 'power_consumption_end': '2025-02-08T18:10:11Z', 'power_consumption_start': '2025-02-07T04:00:19Z', 'state_class': , @@ -3280,7 +3280,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3295,8 +3295,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3307,39 +3307,37 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer powerEnergy', + 'friendly_name': 'Dryer Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dryer_powerenergy', + 'entity_id': 'sensor.dryer_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3347,8 +3345,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_deltaenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3357,31 +3355,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer deltaEnergy', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', - 'unit_of_measurement': , + 'translation_key': 'completion_time', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Washer deltaEnergy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'Washer Completion time', }), 'context': , - 'entity_id': 'sensor.washer_deltaenergy', + 'entity_id': 'sensor.washer_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '2025-02-07T03:54:45+00:00', }) # --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-entry] @@ -3400,7 +3396,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.washer_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3411,7 +3407,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3424,7 +3420,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer energy', + 'friendly_name': 'Washer Energy', 'state_class': , 'unit_of_measurement': , }), @@ -3436,7 +3432,7 @@ 'state': '352.8', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3451,8 +3447,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3463,31 +3459,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer energySaved', + 'friendly_name': 'Washer Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.washer_energysaved', + 'entity_id': 'sensor.washer_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Job state', + }), + 'context': , + 'entity_id': 'sensor.washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Machine state', + }), + 'context': , + 'entity_id': 'sensor.washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3504,7 +3646,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.washer_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3515,7 +3657,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3528,7 +3670,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Washer power', + 'friendly_name': 'Washer Power', 'power_consumption_end': '2025-02-07T03:09:45Z', 'power_consumption_start': '2025-02-07T03:09:24Z', 'state_class': , @@ -3542,7 +3684,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3557,8 +3699,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3569,174 +3711,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer powerEnergy', + 'friendly_name': 'Washer Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.washer_powerenergy', + 'entity_id': 'sensor.washer_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.washer_washer_completion_time', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Washer Washer Completion Time', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Washer Washer Completion Time', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_completion_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-02-07T03:54:45+00:00', - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.washer_washer_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Washer Washer Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer Washer Job State', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.washer_washer_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Washer Washer Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer Washer Machine State', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- -# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-entry] +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3751,8 +3751,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.child_bedroom_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.child_bedroom_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3763,7 +3763,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3772,23 +3772,23 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-state] +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Child Bedroom Temperature Measurement', + 'friendly_name': 'Child Bedroom Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'entity_id': 'sensor.child_bedroom_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '22', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-entry] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3803,8 +3803,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.main_floor_relative_humidity_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.main_floor_humidity', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3815,7 +3815,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Main Floor Relative Humidity Measurement', + 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3824,23 +3824,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-state] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', - 'friendly_name': 'Main Floor Relative Humidity Measurement', + 'friendly_name': 'Main Floor Humidity', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'entity_id': 'sensor.main_floor_humidity', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '32', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-entry] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3855,8 +3855,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.main_floor_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.main_floor_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3867,7 +3867,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Main Floor Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3876,16 +3876,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-state] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Main Floor Temperature Measurement', + 'friendly_name': 'Main Floor Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.main_floor_temperature_measurement', + 'entity_id': 'sensor.main_floor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3906,7 +3906,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.deck_door_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3917,7 +3917,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3941,7 +3941,7 @@ 'state': '50', }) # --- -# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-entry] +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3956,8 +3956,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.deck_door_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.deck_door_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3968,7 +3968,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3977,16 +3977,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-state] +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Deck Door Temperature Measurement', + 'friendly_name': 'Deck Door Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.deck_door_temperature_measurement', + 'entity_id': 'sensor.deck_door_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4007,7 +4007,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_x_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4018,11 +4018,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door X Coordinate', + 'original_name': 'X coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'x_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', 'unit_of_measurement': None, }) @@ -4030,7 +4030,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door X Coordinate', + 'friendly_name': 'Deck Door X coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_x_coordinate', @@ -4054,7 +4054,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_y_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4065,11 +4065,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door Y Coordinate', + 'original_name': 'Y coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'y_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', 'unit_of_measurement': None, }) @@ -4077,7 +4077,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door Y Coordinate', + 'friendly_name': 'Deck Door Y coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_y_coordinate', @@ -4101,7 +4101,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_z_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4112,11 +4112,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door Z Coordinate', + 'original_name': 'Z coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'z_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', 'unit_of_measurement': None, }) @@ -4124,7 +4124,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door Z Coordinate', + 'friendly_name': 'Deck Door Z coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_z_coordinate', @@ -4148,7 +4148,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.office_air_conditioner_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4159,11 +4159,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Office Air Conditioner Mode', + 'original_name': 'Air conditioner mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_conditioner_mode', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', 'unit_of_measurement': None, }) @@ -4171,7 +4171,7 @@ # name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Office Air Conditioner Mode', + 'friendly_name': 'Office Air conditioner mode', }), 'context': , 'entity_id': 'sensor.office_air_conditioner_mode', @@ -4181,7 +4181,7 @@ 'state': 'cool', }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-entry] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4194,8 +4194,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_thermostat_cooling_setpoint', - 'has_entity_name': False, + 'entity_id': 'sensor.office_cooling_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4206,24 +4206,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Office Thermostat Cooling Setpoint', + 'original_name': 'Cooling set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'thermostat_cooling_setpoint', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-state] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Office Thermostat Cooling Setpoint', + 'friendly_name': 'Office Cooling set point', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'entity_id': 'sensor.office_cooling_set_point', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4244,7 +4244,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4255,11 +4255,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Elliots Rum Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', 'unit_of_measurement': None, }) @@ -4267,7 +4267,7 @@ # name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Elliots Rum Media Playback Status', + 'friendly_name': 'Elliots Rum Media playback status', }), 'context': , 'entity_id': 'sensor.elliots_rum_media_playback_status', @@ -4291,7 +4291,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.elliots_rum_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4302,11 +4302,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Elliots Rum Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', 'unit_of_measurement': '%', }) @@ -4339,7 +4339,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4350,11 +4350,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', 'unit_of_measurement': None, }) @@ -4362,7 +4362,7 @@ # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living Media Playback Status', + 'friendly_name': 'Soundbar Living Media playback status', }), 'context': , 'entity_id': 'sensor.soundbar_living_media_playback_status', @@ -4386,7 +4386,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.soundbar_living_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4397,11 +4397,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', 'unit_of_measurement': '%', }) @@ -4434,7 +4434,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4445,11 +4445,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'original_name': 'Media input source', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_input_source', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', 'unit_of_measurement': None, }) @@ -4457,7 +4457,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', @@ -4481,7 +4481,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4492,11 +4492,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', 'unit_of_measurement': None, }) @@ -4504,7 +4504,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', @@ -4528,7 +4528,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4539,11 +4539,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'original_name': 'TV channel', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tv_channel', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', 'unit_of_measurement': None, }) @@ -4551,7 +4551,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', @@ -4575,7 +4575,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4586,11 +4586,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'original_name': 'TV channel name', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tv_channel_name', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', 'unit_of_measurement': None, }) @@ -4598,7 +4598,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel name', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', @@ -4622,7 +4622,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4633,11 +4633,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', 'unit_of_measurement': '%', }) @@ -4670,7 +4670,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.asd_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4681,7 +4681,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4705,7 +4705,7 @@ 'state': '100', }) # --- -# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-entry] +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4720,8 +4720,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.asd_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.asd_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4732,7 +4732,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4741,16 +4741,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-state] +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'asd Temperature Measurement', + 'friendly_name': 'asd Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.asd_temperature_measurement', + 'entity_id': 'sensor.asd_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4771,7 +4771,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.asd_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4782,7 +4782,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4820,7 +4820,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.basement_door_lock_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4831,7 +4831,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Basement Door Lock Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index cf3245eed7d..d12bd4ea5b6 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -13,7 +13,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.2nd_floor_hallway', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '2nd Floor Hallway', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -60,7 +60,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.microwave', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -71,7 +71,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -107,7 +107,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.robot_vacuum', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -118,7 +118,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -154,7 +154,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.dishwasher', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -165,7 +165,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dishwasher', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -201,7 +201,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.dryer', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -212,7 +212,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dryer', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -248,7 +248,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.washer', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -259,7 +259,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Washer', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -295,7 +295,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.office', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -306,7 +306,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Office', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -342,7 +342,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.arlo_beta_basestation', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -353,7 +353,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Arlo Beta Basestation', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -389,7 +389,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.soundbar_living', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -400,7 +400,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -436,7 +436,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.tv_samsung_8_series_49', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -447,7 +447,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49)', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index eb473d3be04..f46be2edc89 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -39,7 +39,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF await trigger_update( hass, @@ -50,4 +50,4 @@ async def test_state_update( "open", ) - assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 7f8464e69aa..8b8bb8930f4 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -37,10 +37,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert ( - hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state - == "19978.536" - ) + assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" await trigger_update( hass, @@ -51,6 +48,4 @@ async def test_state_update( 20000.0, ) - assert ( - hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state == "20000.0" - ) + assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" From e09b40c2bd7d4a6822dfc9a80eb53bae248e2160 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:51:16 +0100 Subject: [PATCH 1292/1435] Improve logging for selected options in Onkyo (#139279) Different error for not selected option --- .../components/onkyo/media_player.py | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 7c91fda5f78..8f9587bc426 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -398,6 +398,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._volume_resolution = volume_resolution self._max_volume = max_volume + self._options_sources = sources self._source_lib_mapping = _input_source_lib_mappings(zone) self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) self._source_mapping = { @@ -409,6 +410,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): value: key for key, value in self._source_mapping.items() } + self._options_sound_modes = sound_modes self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) self._sound_mode_mapping = { @@ -623,11 +625,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity): return source_meaning = source.value_meaning - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) + + if source not in self._options_sources: + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Input source "%s" is invalid for entity: %s', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning @callback @@ -638,11 +649,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity): return sound_mode_meaning = sound_mode.value_meaning - _LOGGER.error( - 'Listening mode "%s" is invalid for entity: %s', - sound_mode_meaning, - self.entity_id, - ) + + if sound_mode not in self._options_sound_modes: + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Listening mode "%s" is invalid for entity: %s', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning @callback From 9be8fd4eac934066f67982931f74d7c4ee451b95 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:59:23 +0100 Subject: [PATCH 1293/1435] Change no fixtures comment in SmartThings (#139344) --- .../components/smartthings/sensor.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 6685d6be726..9c544ea5d73 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -64,7 +64,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): CAPABILITY_TO_SENSORS: dict[ Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]] ] = { - # no fixtures + # Haven't seen at devices yet Capability.ACTIVITY_LIGHTING_MODE: { Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( @@ -126,7 +126,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.BODY_MASS_INDEX_MEASUREMENT: { Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -137,7 +137,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.BODY_WEIGHT_MEASUREMENT: { Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -149,7 +149,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_DIOXIDE_MEASUREMENT: { Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( @@ -160,7 +160,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_MONOXIDE_DETECTOR: { Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( @@ -169,7 +169,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_MONOXIDE_MEASUREMENT: { Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( @@ -202,7 +202,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.DRYER_MODE: { Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( @@ -260,7 +260,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: { Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -272,7 +272,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.FORMALDEHYDE_MEASUREMENT: { Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( @@ -283,7 +283,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.GAS_METER: { Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( @@ -317,7 +317,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.ILLUMINANCE_MEASUREMENT: { Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( @@ -328,7 +328,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.INFRARED_LEVEL: { Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( @@ -347,7 +347,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_REPEAT: { Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( @@ -356,7 +356,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_SHUFFLE: { Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( @@ -471,7 +471,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.POWER_SOURCE: { Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( @@ -527,7 +527,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.SIGNAL_STRENGTH: { Attribute.LQI: [ SmartThingsSensorEntityDescription( @@ -546,7 +546,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.SMOKE_DETECTOR: { Attribute.SMOKE: [ SmartThingsSensorEntityDescription( @@ -581,7 +581,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_FAN_MODE: { Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( @@ -592,7 +592,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_HEATING_SETPOINT: { Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( @@ -604,7 +604,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_MODE: { Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( @@ -615,7 +615,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_OPERATING_STATE: { Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( @@ -672,7 +672,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.TVOC_MEASUREMENT: { Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( @@ -683,7 +683,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.ULTRAVIOLET_INDEX: { Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( From e403bee95b87e138761a51dab9ba2d40ec472508 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:05:59 +0100 Subject: [PATCH 1294/1435] Set options for carbon monoxide detector sensor in SmartThings (#139346) --- homeassistant/components/smartthings/sensor.py | 2 ++ homeassistant/components/smartthings/strings.json | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 9c544ea5d73..da4fa20526e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -166,6 +166,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE, translation_key="carbon_monoxide_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9cfc6176d20..9076aa8b2b5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -61,7 +61,12 @@ "name": "Body weight" }, "carbon_monoxide_detector": { - "name": "Carbon monoxide detector" + "name": "Carbon monoxide detector", + "state": { + "detected": "Detected", + "clear": "Clear", + "tested": "Tested" + } }, "dishwasher_machine_state": { "name": "Machine state" From fdf69fcd7dea7f708664fba22ded72a8cb313bd9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Feb 2025 16:09:20 +0100 Subject: [PATCH 1295/1435] Improve calculating supported features in template light (#139339) --- homeassistant/components/template/light.py | 2 +- tests/components/template/test_light.py | 54 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 9391e368e2b..206703ddcce 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1013,7 +1013,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return - self._attr_supported_features &= LightEntityFeature.EFFECT + self._attr_supported_features &= ~LightEntityFeature.TRANSITION self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index b5ba93a4bd0..a94ec233f81 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -1847,6 +1847,60 @@ async def test_supports_transition_template( ) != expected_value +@pytest.mark.parametrize("count", [1]) +async def test_supports_transition_template_updates( + hass: HomeAssistant, count: int +) -> None: + """Test the template for the supports transition dynamically.""" + light_config = { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "set_effect": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + "effect_list_template": "{{ ['Disco', 'Police'] }}", + "effect_template": "{{ None }}", + "supports_transition_template": "{{ states('sensor.test') }}", + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state is not None + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + hass.states.async_set("sensor.test", 1) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert ( + supported_features == LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + ) + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( "light_config", From c1898ece8068c8573989c168182de05519917ff6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 16:13:45 +0100 Subject: [PATCH 1296/1435] Update frontend to 20250226.0 (#139340) Co-authored-by: Robert Resch --- 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 b13b33685d5..7bd361041e1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250221.0"] + "requirements": ["home-assistant-frontend==20250226.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6a6c1dfc3ed..b248be0eb96 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.0 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 54c0a29bee5..082524036e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3f171fa1a9..8cac6cc79d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 3c3c4d2641e2405ca3fa8731992e44e26bbaa7f6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:17:55 +0100 Subject: [PATCH 1297/1435] Use particulate matter device class in SmartThings (#139351) Use particule matter device class in SmartThings --- .../components/smartthings/sensor.py | 7 +- .../components/smartthings/strings.json | 6 - .../smartthings/snapshots/test_sensor.ambr | 410 +++++++++--------- 3 files changed, 213 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index da4fa20526e..ec4fc94ae80 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -240,14 +241,16 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.DUST_LEVEL, - translation_key="dust_level", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ) ], Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FINE_DUST_LEVEL, - translation_key="fine_dust_level", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ) ], diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9076aa8b2b5..9d7ea5938f5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -86,12 +86,6 @@ "dryer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" }, - "dust_level": { - "name": "Dust level" - }, - "fine_dust_level": { - "name": "Fine dust level" - }, "equivalent_carbon_dioxide": { "name": "Equivalent carbon dioxide" }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 2fca1a8d108..8f8f514ef07 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -558,57 +558,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_dust_level', - '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': 'Dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dust_level', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Dust level', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -765,57 +714,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - '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': 'Fine dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fine_dust_level', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Fine dust level', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -868,6 +766,110 @@ 'state': '60', }) # --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'AC Office Granit PM10', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'AC Office Granit PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1125,56 +1127,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - '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': 'Dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dust_level', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Dust level', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1331,56 +1283,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - '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': 'Fine dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fine_dust_level', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Fine dust level', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1480,6 +1382,110 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Aire Dormitorio Principal PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Aire Dormitorio Principal PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 9262dec4443ef8ef62464cdd798294b9d40e21dc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:14 +0100 Subject: [PATCH 1298/1435] Set options for dishwasher job state sensor in SmartThings (#139349) --- .../components/smartthings/sensor.py | 21 +++++++++++++ .../components/smartthings/strings.json | 14 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 30 +++++++++++++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ec4fc94ae80..feac0b4a09b 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -42,6 +42,13 @@ THERMOSTAT_CAPABILITIES = { Capability.THERMOSTAT_MODE, } +JOB_STATE_MAP = { + "preDrain": "pre_drain", + "preWash": "pre_wash", + "wrinklePrevent": "wrinkle_prevent", + "unknown": None, +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -194,6 +201,20 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.DISHWASHER_JOB_STATE, translation_key="dishwasher_job_state", + options=[ + "airwash", + "cooling", + "drying", + "finish", + "pre_drain", + "pre_wash", + "rinse", + "spin", + "wash", + "wrinkle_prevent", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9d7ea5938f5..7ee3e57ac64 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -72,7 +72,19 @@ "name": "Machine state" }, "dishwasher_job_state": { - "name": "Job state" + "name": "Job state", + "state": { + "airwash": "Airwash", + "cooling": "Cooling", + "drying": "Drying", + "finish": "Finish", + "pre_drain": "Pre-drain", + "pre_wash": "Pre-wash", + "rinse": "Rinse", + "spin": "Spin", + "wash": "Wash", + "wrinkle_prevent": "Wrinkle prevention" + } }, "completion_time": { "name": "Completion time" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8f8f514ef07..0df93a3a02a 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2739,7 +2739,20 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'airwash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2757,7 +2770,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -2771,7 +2784,20 @@ # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dishwasher Job state', + 'options': list([ + 'airwash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), }), 'context': , 'entity_id': 'sensor.dishwasher_job_state', From 37c8764426adb42150c4ec19a36661d43b8ee457 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:37 +0100 Subject: [PATCH 1299/1435] Set options for dishwasher machine state sensor in SmartThings (#139347) * Set options for dishwasher machine state sensor in SmartThings * Fix --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index feac0b4a09b..fb40632626f 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -195,6 +195,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dishwasher_machine_state", + options=["pause", "run", "stop"], + device_class=SensorDeviceClass.ENUM, ) ], Attribute.DISHWASHER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7ee3e57ac64..a577d1267d7 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -69,7 +69,12 @@ } }, "dishwasher_machine_state": { - "name": "Machine state" + "name": "Machine state", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "Running", + "stop": "Stopped" + } }, "dishwasher_job_state": { "name": "Job state", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 0df93a3a02a..01156462455 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2812,7 +2812,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2830,7 +2836,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -2844,7 +2850,13 @@ # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dishwasher Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.dishwasher_machine_state', From bd80a7884888d9524ae79c000d0813775a615d6f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:59 +0100 Subject: [PATCH 1300/1435] Set options for alarm sensor in SmartThings (#139345) * Set options for alarm sensor in SmartThings * Set options for alarm sensor in SmartThings * Fix --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 8 +++++++- .../smartthings/snapshots/test_sensor.ambr | 18 ++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index fb40632626f..73cc8c32a09 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -112,6 +112,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ALARM, translation_key="alarm", + options=["both", "strobe", "siren", "off"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index a577d1267d7..2faf3df682d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -49,7 +49,13 @@ "name": "Air quality" }, "alarm": { - "name": "Alarm" + "name": "Alarm", + "state": { + "both": "Strobe and siren", + "strobe": "Strobe", + "siren": "Siren", + "off": "[%key:common::state::off%]" + } }, "audio_volume": { "name": "Volume" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 01156462455..77d7ddf6643 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -263,7 +263,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -281,7 +288,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Alarm', 'platform': 'smartthings', @@ -295,7 +302,14 @@ # name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '2nd Floor Hallway Alarm', + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), }), 'context': , 'entity_id': 'sensor.2nd_floor_hallway_alarm', From b964bc58bef0671acd205b8d2da12b2e24054a64 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 26 Feb 2025 16:19:19 +0100 Subject: [PATCH 1301/1435] Fix variable scopes in scripts (#138883) Co-authored-by: Erik --- homeassistant/helpers/script.py | 103 +++++----- homeassistant/helpers/script_variables.py | 218 ++++++++++++++++++++-- tests/helpers/test_script.py | 146 +++++++++++++++ tests/helpers/test_script_variables.py | 124 +++++++++--- 4 files changed, 504 insertions(+), 87 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 38bc96b67ef..bf7a4a0971c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -12,7 +12,6 @@ from datetime import datetime, timedelta from functools import partial import itertools import logging -from types import MappingProxyType from typing import Any, Literal, TypedDict, cast, overload import async_interrupt @@ -90,7 +89,7 @@ from . import condition, config_validation as cv, service, template from .condition import ConditionCheckerType, trace_condition_function from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template -from .script_variables import ScriptVariables +from .script_variables import ScriptRunVariables, ScriptVariables from .template import Template from .trace import ( TraceElement, @@ -177,7 +176,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None: future.set_result(None) -def action_trace_append(variables: dict[str, Any], path: str) -> TraceElement: +def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: """Append a TraceElement to trace[path].""" trace_element = TraceElement(variables, path) trace_append_element(trace_element, ACTION_TRACE_NODE_MAX_LEN) @@ -189,7 +188,7 @@ async def trace_action( hass: HomeAssistant, script_run: _ScriptRun, stop: asyncio.Future[None], - variables: dict[str, Any], + variables: TemplateVarsType, ) -> AsyncGenerator[TraceElement]: """Trace action execution.""" path = trace_path_get() @@ -411,7 +410,7 @@ class _ScriptRun: self, hass: HomeAssistant, script: Script, - variables: dict[str, Any], + variables: ScriptRunVariables, context: Context | None, log_exceptions: bool, ) -> None: @@ -485,14 +484,16 @@ class _ScriptRun: script_stack.pop() self._finish() - return ScriptRunResult(self._conversation_response, response, self._variables) + return ScriptRunResult( + self._conversation_response, response, self._variables.local_scope + ) async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): async with trace_action( - self._hass, self, self._stop, self._variables + self._hass, self, self._stop, self._variables.non_parallel_scope ) as trace_element: if self._stop.done(): return @@ -526,7 +527,7 @@ class _ScriptRun: ex, continue_on_error, self._log_exceptions or log_exceptions ) finally: - trace_element.update_variables(self._variables) + trace_element.update_variables(self._variables.non_parallel_scope) def _finish(self) -> None: self._script._runs.remove(self) # noqa: SLF001 @@ -624,11 +625,16 @@ class _ScriptRun: except ScriptStoppedError as ex: raise asyncio.CancelledError from ex - async def _async_run_script(self, script: Script) -> None: + async def _async_run_script( + self, script: Script, *, parallel: bool = False + ) -> None: """Execute a script.""" result = await self._async_run_long_action( self._hass.async_create_task_internal( - script.async_run(self._variables, self._context), eager_start=True + script.async_run( + self._variables.enter_scope(parallel=parallel), self._context + ), + eager_start=True, ) ) if result and result.conversation_response is not UNDEFINED: @@ -647,7 +653,7 @@ class _ScriptRun: """Run a script with a trace path.""" trace_path_stack_cv.set(copy(trace_path_stack_cv.get())) with trace_path([str(idx), "sequence"]): - await self._async_run_script(script) + await self._async_run_script(script, parallel=True) results = await asyncio.gather( *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)), @@ -760,14 +766,11 @@ class _ScriptRun: with trace_path("else"): await self._async_run_script(if_data["if_else"]) - @async_trace_path("repeat") - async def _async_step_repeat(self) -> None: # noqa: C901 - """Repeat a sequence.""" + async def _async_do_step_repeat(self) -> None: # noqa: C901 + """Repeat a sequence helper.""" description = self._action.get(CONF_ALIAS, "sequence") repeat = self._action[CONF_REPEAT] - saved_repeat_vars = self._variables.get("repeat") - def set_repeat_var( iteration: int, count: int | None = None, item: Any = None ) -> None: @@ -776,7 +779,7 @@ class _ScriptRun: repeat_vars["last"] = iteration == count if item is not None: repeat_vars["item"] = item - self._variables["repeat"] = repeat_vars + self._variables.define_local("repeat", repeat_vars) script = self._script._get_repeat_script(self._step) # noqa: SLF001 warned_too_many_loops = False @@ -927,10 +930,14 @@ class _ScriptRun: # while all the cpu time is consumed. await asyncio.sleep(0) - if saved_repeat_vars: - self._variables["repeat"] = saved_repeat_vars - else: - self._variables.pop("repeat", None) # Not set if count = 0 + @async_trace_path("repeat") + async def _async_step_repeat(self) -> None: + """Repeat a sequence.""" + self._variables = self._variables.enter_scope() + try: + await self._async_do_step_repeat() + finally: + self._variables = self._variables.exit_scope() ### Stop actions ### @@ -959,11 +966,12 @@ class _ScriptRun: ## Variable actions ## async def _async_step_variables(self) -> None: - """Set a variable value.""" - self._step_log("setting variables") - self._variables = self._action[CONF_VARIABLES].async_render( - self._hass, self._variables, render_as_defaults=False - ) + """Define a local variable.""" + self._step_log("defining local variables") + for key, value in ( + self._action[CONF_VARIABLES].async_simple_render(self._variables).items() + ): + self._variables.define_local(key, value) ## External actions ## @@ -1016,7 +1024,7 @@ class _ScriptRun: """Perform the device automation specified in the action.""" self._step_log("device automation") await device_action.async_call_action_from_config( - self._hass, self._action, self._variables, self._context + self._hass, self._action, dict(self._variables), self._context ) async def _async_step_event(self) -> None: @@ -1189,12 +1197,15 @@ class _ScriptRun: self._step_log("wait for trigger", timeout) - variables = {**self._variables} - self._variables["wait"] = { - "remaining": timeout, - "completed": False, - "trigger": None, - } + variables = dict(self._variables) + self._variables.assign_parallel_protected( + "wait", + { + "remaining": timeout, + "completed": False, + "trigger": None, + }, + ) trace_set_result(wait=self._variables["wait"]) if timeout == 0: @@ -1240,7 +1251,9 @@ class _ScriptRun: timeout = self._get_timeout_seconds_from_action() self._step_log("wait template", timeout) - self._variables["wait"] = {"remaining": timeout, "completed": False} + self._variables.assign_parallel_protected( + "wait", {"remaining": timeout, "completed": False} + ) trace_set_result(wait=self._variables["wait"]) wait_template = self._action[CONF_WAIT_TEMPLATE] @@ -1369,7 +1382,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> ) -type _VarsType = dict[str, Any] | Mapping[str, Any] | MappingProxyType[str, Any] +type _VarsType = dict[str, Any] | Mapping[str, Any] | ScriptRunVariables def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: @@ -1407,7 +1420,7 @@ class ScriptRunResult: conversation_response: str | None | UndefinedType service_response: ServiceResponse - variables: dict[str, Any] + variables: Mapping[str, Any] class Script: @@ -1422,7 +1435,6 @@ class Script: *, # Used in "Running " log message change_listener: Callable[[], Any] | None = None, - copy_variables: bool = False, log_exceptions: bool = True, logger: logging.Logger | None = None, max_exceeded: str = DEFAULT_MAX_EXCEEDED, @@ -1476,8 +1488,6 @@ class Script: self._parallel_scripts: dict[int, list[Script]] = {} self._sequence_scripts: dict[int, Script] = {} self.variables = variables - self._variables_dynamic = template.is_complex(variables) - self._copy_variables_on_run = copy_variables @property def change_listener(self) -> Callable[..., Any] | None: @@ -1755,25 +1765,19 @@ class Script: if self.top_level: if self.variables: try: - variables = self.variables.async_render( + run_variables = self.variables.async_render( self._hass, run_variables, ) except exceptions.TemplateError as err: self._log("Error rendering variables: %s", err, level=logging.ERROR) raise - elif run_variables: - variables = dict(run_variables) - else: - variables = {} + variables = ScriptRunVariables.create_top_level(run_variables) variables["context"] = context - elif self._copy_variables_on_run: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], copy(run_variables)) else: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], run_variables) + # This is not the top level script, run_variables is an instance of ScriptRunVariables + variables = cast(ScriptRunVariables, run_variables) # Prevent non-allowed recursive calls which will cause deadlocks when we try to # stop (restart) or wait for (queued) our own script run. @@ -1999,7 +2003,6 @@ class Script: max_runs=self.max_runs, logger=self._logger, top_level=False, - copy_variables=True, ) parallel_script.change_listener = partial( self._chain_change_listener, parallel_script diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 2b4507abd64..54200e094e6 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections import ChainMap, UserDict from collections.abc import Mapping -from typing import Any +from dataclasses import dataclass, field +from typing import Any, cast from homeassistant.core import HomeAssistant, callback @@ -24,30 +26,23 @@ class ScriptVariables: hass: HomeAssistant, run_variables: Mapping[str, Any] | None, *, - render_as_defaults: bool = True, limited: bool = False, ) -> dict[str, Any]: """Render script variables. - The run variables are used to compute the static variables. - - If `render_as_defaults` is True, the run variables will not be overridden. - + The run variables are included in the result. + The run variables are used to compute the rendered variable values. + The run variables will not be overridden. + The rendering happens one at a time, with previous results influencing the next. """ if self._has_template is None: self._has_template = template.is_complex(self.variables) if not self._has_template: - if render_as_defaults: - rendered_variables = dict(self.variables) + rendered_variables = dict(self.variables) - if run_variables is not None: - rendered_variables.update(run_variables) - else: - rendered_variables = ( - {} if run_variables is None else dict(run_variables) - ) - rendered_variables.update(self.variables) + if run_variables is not None: + rendered_variables.update(run_variables) return rendered_variables @@ -56,7 +51,7 @@ class ScriptVariables: for key, value in self.variables.items(): # We can skip if we're going to override this key with # run variables anyway - if render_as_defaults and key in rendered_variables: + if key in rendered_variables: continue rendered_variables[key] = template.render_complex( @@ -65,6 +60,197 @@ class ScriptVariables: return rendered_variables + @callback + def async_simple_render(self, run_variables: Mapping[str, Any]) -> dict[str, Any]: + """Render script variables. + + Simply renders the variables, the run variables are not included in the result. + The run variables are used to compute the rendered variable values. + The rendering happens one at a time, with previous results influencing the next. + """ + if self._has_template is None: + self._has_template = template.is_complex(self.variables) + + if not self._has_template: + return self.variables + + run_variables = dict(run_variables) + rendered_variables = {} + + for key, value in self.variables.items(): + rendered_variable = template.render_complex(value, run_variables) + rendered_variables[key] = rendered_variable + run_variables[key] = rendered_variable + + return rendered_variables + def as_dict(self) -> dict[str, Any]: """Return dict version of this class.""" return self.variables + + +@dataclass +class _ParallelData: + """Data used in each parallel sequence.""" + + # `protected` is for variables that need special protection in parallel sequences. + # What this means is that such a variable defined in one parallel sequence will not be + # clobbered by the variable with the same name assigned in another parallel sequence. + # It also means that such a variable will not be visible in the outer scope. + # Currently the only such variable is `wait`. + protected: dict[str, Any] = field(default_factory=dict) + # `outer_scope_writes` is for variables that are written to the outer scope from + # a parallel sequence. This is used for generating correct traces of changed variables + # for each of the parallel sequences, isolating them from one another. + outer_scope_writes: dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class ScriptRunVariables(UserDict[str, Any]): + """Class to hold script run variables. + + The purpose of this class is to provide proper variable scoping semantics for scripts. + Each instance institutes a new local scope, in which variables can be defined. + Each instance has a reference to the previous instance, except for the top-level instance. + The instances therefore form a chain, in which variable lookup and assignment is performed. + The variables defined lower in the chain naturally override those defined higher up. + """ + + # _previous is the previous ScriptRunVariables in the chain + _previous: ScriptRunVariables | None = None + # _parent is the previous non-empty ScriptRunVariables in the chain + _parent: ScriptRunVariables | None = None + + # _local_data is the store for local variables + _local_data: dict[str, Any] | None = None + # _parallel_data is used for each parallel sequence + _parallel_data: _ParallelData | None = None + + # _non_parallel_scope includes all scopes all the way to the most recent parallel split + _non_parallel_scope: ChainMap[str, Any] + # _full_scope includes all scopes (all the way to the top-level) + _full_scope: ChainMap[str, Any] + + @classmethod + def create_top_level( + cls, + initial_data: Mapping[str, Any] | None = None, + ) -> ScriptRunVariables: + """Create a new top-level ScriptRunVariables.""" + local_data: dict[str, Any] = {} + non_parallel_scope = full_scope = ChainMap(local_data) + self = cls( + _local_data=local_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + if initial_data is not None: + self.update(initial_data) + return self + + def enter_scope(self, *, parallel: bool = False) -> ScriptRunVariables: + """Return a new child scope. + + :param parallel: Whether the new scope starts a parallel sequence. + """ + if self._local_data is not None or self._parallel_data is not None: + parent = self + else: + parent = cast( # top level always has local data, so we can cast safely + ScriptRunVariables, self._parent + ) + + parallel_data: _ParallelData | None + if not parallel: + parallel_data = None + non_parallel_scope = self._non_parallel_scope + full_scope = self._full_scope + else: + parallel_data = _ParallelData() + non_parallel_scope = ChainMap( + parallel_data.protected, parallel_data.outer_scope_writes + ) + full_scope = self._full_scope.new_child(parallel_data.protected) + + return ScriptRunVariables( + _previous=self, + _parent=parent, + _parallel_data=parallel_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + + def exit_scope(self) -> ScriptRunVariables: + """Exit the current scope. + + Does no clean-up, but simply returns the previous scope. + """ + if self._previous is None: + raise ValueError("Cannot exit top-level scope") + return self._previous + + def __delitem__(self, key: str) -> None: + """Delete a variable (disallowed).""" + raise TypeError("Deleting items is not allowed in ScriptRunVariables.") + + def __setitem__(self, key: str, value: Any) -> None: + """Assign value to a variable.""" + self._assign(key, value, parallel_protected=False) + + def assign_parallel_protected(self, key: str, value: Any) -> None: + """Assign value to a variable which is to be protected in parallel sequences.""" + self._assign(key, value, parallel_protected=True) + + def _assign(self, key: str, value: Any, *, parallel_protected: bool) -> None: + """Assign value to a variable. + + Value is always assigned to the variable in the nearest scope, in which it is defined. + If the variable is not defined at all, it is created in the top-level scope. + + :param parallel_protected: Whether variable is to be protected in parallel sequences. + """ + if self._local_data is not None and key in self._local_data: + self._local_data[key] = value + return + + if self._parent is None: + assert self._local_data is not None # top level always has local data + self._local_data[key] = value + return + + if self._parallel_data is not None: + if parallel_protected: + self._parallel_data.protected[key] = value + return + self._parallel_data.protected.pop(key, None) + self._parallel_data.outer_scope_writes[key] = value + + self._parent._assign(key, value, parallel_protected=parallel_protected) # noqa: SLF001 + + def define_local(self, key: str, value: Any) -> None: + """Define a local variable and assign value to it.""" + if self._local_data is None: + self._local_data = {} + self._non_parallel_scope = self._non_parallel_scope.new_child( + self._local_data + ) + self._full_scope = self._full_scope.new_child(self._local_data) + self._local_data[key] = value + + @property + def data(self) -> Mapping[str, Any]: # type: ignore[override] + """Return variables in full scope. + + Defined here for UserDict compatibility. + """ + return self._full_scope + + @property + def non_parallel_scope(self) -> Mapping[str, Any]: + """Return variables in non-parallel scope.""" + return self._non_parallel_scope + + @property + def local_scope(self) -> Mapping[str, Any]: + """Return variables in local scope.""" + return self._local_data if self._local_data is not None else {} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index f3cbb982ad0..df589a41daa 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -452,6 +452,68 @@ async def test_service_response_data_errors( await script_obj.async_run(context=context) +async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> None: + """Test response variable is still set after scopes end.""" + expected_var = {"data": "value-12345"} + + def mock_service(call: ServiceCall) -> ServiceResponse: + """Mock service call.""" + if call.return_response: + return expected_var + return None + + hass.services.async_register( + "test", "script", mock_service, supports_response=SupportsResponse.OPTIONAL + ) + + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "service step1", + "action": "test.script", + "response_variable": "my_response", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + assert result.variables["my_response"] == expected_var + + expected_trace = { + "0": [{"variables": {"my_response": expected_var}}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {}, + "target": {}, + }, + "running_script": False, + }, + "variables": {"my_response": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: """Test the calling of a service with a data_template with a templated key.""" context = Context() @@ -1706,6 +1768,90 @@ async def test_wait_variables_out(hass: HomeAssistant, mode, action_type) -> Non assert float(remaining) == 0.0 +async def test_wait_in_sequence(hass: HomeAssistant) -> None: + """Test wait variable is still set after sequence ends.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + }, + ] + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert result.variables["wait"] == expected_var + + expected_trace = { + "0": [{"variables": {"wait": expected_var}}], + "0/sequence/0": [{"variables": {"state": "off"}}], + "0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + +async def test_wait_in_parallel(hass: HomeAssistant) -> None: + """Test wait variable is not set after parallel ends.""" + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert "wait" not in result.variables + + expected_trace = { + "0": [{}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_wait_for_trigger_bad( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_script_variables.py b/tests/helpers/test_script_variables.py index 3675c857279..974a91674a7 100644 --- a/tests/helpers/test_script_variables.py +++ b/tests/helpers/test_script_variables.py @@ -5,12 +5,13 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.script_variables import ScriptRunVariables, ScriptVariables async def test_static_vars() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, None) assert rendered is not orig assert rendered == orig @@ -20,31 +21,28 @@ async def test_static_vars_run_args() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, {"hello": "override", "run": "var"}) assert rendered == {"hello": "override", "run": "var"} # Make sure we don't change original vars assert orig == orig_copy -async def test_static_vars_no_default() -> None: +async def test_static_vars_simple() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render(None, None, render_as_defaults=False) - assert rendered is not orig - assert rendered == orig + var = ScriptVariables(orig) + rendered = var.async_simple_render({}) + assert rendered is orig -async def test_static_vars_run_args_no_default() -> None: +async def test_static_vars_run_args_simple() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render( - None, {"hello": "override", "run": "var"}, render_as_defaults=False - ) - assert rendered == {"hello": "world", "run": "var"} + var = ScriptVariables(orig) + rendered = var.async_simple_render({"hello": "override", "run": "var"}) + assert rendered is orig # Make sure we don't change original vars assert orig == orig_copy @@ -78,14 +76,14 @@ async def test_template_vars_run_args(hass: HomeAssistant) -> None: } -async def test_template_vars_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ 1 + 1 }}"}) - rendered = var.async_render(hass, None, render_as_defaults=False) + rendered = var.async_simple_render({}) assert rendered == {"hello": 2} -async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_run_args_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA( { @@ -93,16 +91,13 @@ async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: "something_2": "{{ run_var_ex + 1 }}", } ) - rendered = var.async_render( - hass, + rendered = var.async_simple_render( { "run_var_ex": 5, "something_2": 1, - }, - render_as_defaults=False, + } ) assert rendered == { - "run_var_ex": 5, "something": 6, "something_2": 6, } @@ -113,3 +108,90 @@ async def test_template_vars_error(hass: HomeAssistant) -> None: var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ canont.work }}"}) with pytest.raises(TemplateError): var.async_render(hass, None) + + +async def test_script_vars_exit_top_level() -> None: + """Test exiting top level script run variables.""" + script_vars = ScriptRunVariables.create_top_level() + with pytest.raises(ValueError): + script_vars.exit_scope() + + +async def test_script_vars_delete_var() -> None: + """Test deleting from script run variables.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 2}) + with pytest.raises(TypeError): + del script_vars["x"] + with pytest.raises(TypeError): + script_vars.pop("y") + assert script_vars._full_scope == {"x": 1, "y": 2} + + +async def test_script_vars_scopes() -> None: + """Test script run variables scopes.""" + script_vars = ScriptRunVariables.create_top_level() + script_vars["x"] = 1 + script_vars["y"] = 1 + assert script_vars["x"] == 1 + assert script_vars["y"] == 1 + + script_vars_2 = script_vars.enter_scope() + script_vars_2.define_local("x", 2) + assert script_vars_2["x"] == 2 + assert script_vars_2["y"] == 1 + + script_vars_3 = script_vars_2.enter_scope() + script_vars_3["x"] = 3 + script_vars_3["y"] = 3 + assert script_vars_3["x"] == 3 + assert script_vars_3["y"] == 3 + + script_vars_4 = script_vars_3.enter_scope() + assert script_vars_4["x"] == 3 + assert script_vars_4["y"] == 3 + + assert script_vars_4.exit_scope() is script_vars_3 + + assert script_vars_3._full_scope == {"x": 3, "y": 3} + assert script_vars_3.local_scope == {} + + assert script_vars_3.exit_scope() is script_vars_2 + + assert script_vars_2._full_scope == {"x": 3, "y": 3} + assert script_vars_2.local_scope == {"x": 3} + + assert script_vars_2.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": 1, "y": 3} + assert script_vars.local_scope == {"x": 1, "y": 3} + + +async def test_script_vars_parallel() -> None: + """Test script run variables parallel support.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 1, "z": 1}) + + script_vars_2a = script_vars.enter_scope(parallel=True) + script_vars_3a = script_vars_2a.enter_scope() + + script_vars_2b = script_vars.enter_scope(parallel=True) + script_vars_3b = script_vars_2b.enter_scope() + + script_vars_3a["x"] = "a" + script_vars_3a.assign_parallel_protected("y", "a") + + script_vars_3b["x"] = "b" + script_vars_3b.assign_parallel_protected("y", "b") + + assert script_vars_3a._full_scope == {"x": "b", "y": "a", "z": 1} + assert script_vars_3a.non_parallel_scope == {"x": "a", "y": "a"} + + assert script_vars_3b._full_scope == {"x": "b", "y": "b", "z": 1} + assert script_vars_3b.non_parallel_scope == {"x": "b", "y": "b"} + + assert script_vars_3a.exit_scope() is script_vars_2a + assert script_vars_2a.exit_scope() is script_vars + assert script_vars_3b.exit_scope() is script_vars_2b + assert script_vars_2b.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": "b", "y": 1, "z": 1} + assert script_vars.local_scope == {"x": "b", "y": 1, "z": 1} From 998757f09ee8bda5749633710d95bc88280b2b5e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:40:34 +0100 Subject: [PATCH 1302/1435] Add translatable states to SmartThings media source input (#139353) Add translatable states to media source input --- .../components/smartthings/sensor.py | 14 +++++++++ .../components/smartthings/strings.json | 29 ++++++++++++++++++- .../smartthings/snapshots/test_sensor.ambr | 20 +++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 73cc8c32a09..b77f3245040 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -67,6 +67,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None + options_attribute: Attribute | None = None CAPABILITY_TO_SENSORS: dict[ @@ -374,6 +375,9 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.INPUT_SOURCE, translation_key="media_input_source", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, + value_fn=lambda value: value.lower(), ) ] }, @@ -841,3 +845,13 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): self.get_attribute_value(self.capability, self._attribute) ) return None + + @property + def options(self) -> list[str] | None: + """Return the options for this sensor.""" + if self.entity_description.options_attribute: + options = self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + return [option.lower() for option in options] + return super().options diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 2faf3df682d..d5989288769 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -128,7 +128,34 @@ "name": "Infrared level" }, "media_input_source": { - "name": "Media input source" + "name": "Media input source", + "state": { + "am": "AM", + "fm": "FM", + "cd": "CD", + "hdmi": "HDMI", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "hdmi5": "HDMI 5", + "hdmi6": "HDMI 6", + "digitaltv": "Digital TV", + "usb": "USB", + "youtube": "YouTube", + "aux": "AUX", + "bluetooth": "Bluetooth", + "digital": "Digital", + "melon": "Melon", + "wifi": "Wi-Fi", + "network": "Network", + "optical": "Optical", + "coaxial": "Coaxial", + "analog1": "Analog 1", + "analog2": "Analog 2", + "analog3": "Analog 3", + "phono": "Phono" + } }, "media_playback_repeat": { "name": "Media playback repeat" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 77d7ddf6643..6046b4381b5 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4483,7 +4483,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4501,7 +4508,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media input source', 'platform': 'smartthings', @@ -4515,14 +4522,21 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'HDMI1', + 'state': 'hdmi1', }) # --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] From 775a81829bd87560a874ed9e57c6f08ffd49bff0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:49:00 +0100 Subject: [PATCH 1303/1435] Add translatable states to SmartThings media playback (#139354) Add translatable states to media playback --- .../components/smartthings/sensor.py | 14 ++++ .../smartthings/snapshots/test_sensor.ambr | 66 +++++++++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b77f3245040..0e4e4a11983 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -49,6 +49,10 @@ JOB_STATE_MAP = { "unknown": None, } +MEDIA_PLAYBACK_STATE_MAP = { + "fast forwarding": "fast_forwarding", +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -404,6 +408,16 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_STATUS, translation_key="media_playback_status", + options=[ + "paused", + "playing", + "stopped", + "fast_forwarding", + "rewinding", + "buffering", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), ) ] }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 6046b4381b5..84575008c7a 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4293,7 +4293,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4311,7 +4320,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4325,7 +4334,16 @@ # name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Elliots Rum Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.elliots_rum_media_playback_status', @@ -4388,7 +4406,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4406,7 +4433,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4420,7 +4447,16 @@ # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Soundbar Living Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.soundbar_living_media_playback_status', @@ -4544,7 +4580,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4562,7 +4607,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4576,7 +4621,16 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', From fc1190dafd5a020059466fec76afc18bf6a6ed23 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:59:20 +0100 Subject: [PATCH 1304/1435] Add translatable states to oven mode in SmartThings (#139356) --- .../components/smartthings/sensor.py | 31 ++++++++++ .../components/smartthings/strings.json | 33 +++++++++- .../smartthings/snapshots/test_sensor.ambr | 62 ++++++++++++++++++- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0e4e4a11983..d4f88964eee 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -53,6 +53,34 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +OVEN_MODE = { + "Conventional": "conventional", + "Bake": "bake", + "BottomHeat": "bottom_heat", + "ConvectionBake": "convection_bake", + "ConvectionRoast": "convection_roast", + "Broil": "broil", + "ConvectionBroil": "convection_broil", + "SteamCook": "steam_cook", + "SteamBake": "steam_bake", + "SteamRoast": "steam_roast", + "SteamBottomHeatplusConvection": "steam_bottom_heat_plus_convection", + "Microwave": "microwave", + "MWplusGrill": "microwave_plus_grill", + "MWplusConvection": "microwave_plus_convection", + "MWplusHotBlast": "microwave_plus_hot_blast", + "MWplusHotBlast2": "microwave_plus_hot_blast_2", + "SlimMiddle": "slim_middle", + "SlimStrong": "slim_strong", + "SlowCook": "slow_cook", + "Proof": "proof", + "Dehydrate": "dehydrate", + "Others": "others", + "StrongSteam": "strong_steam", + "Descale": "descale", + "Rinse": "rinse", +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -435,6 +463,9 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.OVEN_MODE, translation_key="oven_mode", entity_category=EntityCategory.DIAGNOSTIC, + options=list(OVEN_MODE.values()), + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_MODE.get(value, value), ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index d5989288769..b88c27fad77 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -170,7 +170,38 @@ "name": "Odor sensor" }, "oven_mode": { - "name": "Oven mode" + "name": "Oven mode", + "state": { + "heating": "Heating", + "grill": "Grill", + "warming": "Warming", + "defrosting": "Defrosting", + "conventional": "Conventional", + "bake": "Bake", + "bottom_heat": "Bottom heat", + "convection_bake": "Convection bake", + "convection_roast": "Convection roast", + "broil": "Broil", + "convection_broil": "Convection broil", + "steam_cook": "Steam cook", + "steam_bake": "Steam bake", + "steam_roast": "Steam roast", + "steam_bottom_heat_plus_convection": "Steam bottom heat plus convection", + "microwave": "Microwave", + "microwave_plus_grill": "Microwave plus grill", + "microwave_plus_convection": "Microwave plus convection", + "microwave_plus_hot_blast": "Microwave plus hot blast", + "microwave_plus_hot_blast_2": "Microwave plus hot blast 2", + "slim_middle": "Slim middle", + "slim_strong": "Slim strong", + "slow_cook": "Slow cook", + "proof": "Proof", + "dehydrate": "Dehydrate", + "others": "Others", + "strong_steam": "Strong steam", + "descale": "Descale", + "rinse": "Rinse" + } }, "oven_machine_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 84575008c7a..41691d26435 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1852,7 +1852,35 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1870,7 +1898,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Oven mode', 'platform': 'smartthings', @@ -1884,14 +1912,42 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), }), 'context': , 'entity_id': 'sensor.microwave_oven_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Others', + 'state': 'others', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] From b777c29bab497a018c4713670cb9eb288b7906e8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:12:27 +0100 Subject: [PATCH 1305/1435] Add translatable states to oven job state in SmartThings (#139361) --- .../components/smartthings/sensor.py | 29 ++++++++++++ .../components/smartthings/strings.json | 21 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 44 ++++++++++++++++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d4f88964eee..91b9a09fd19 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -49,6 +49,14 @@ JOB_STATE_MAP = { "unknown": None, } +OVEN_JOB_STATE_MAP = { + "scheduledStart": "scheduled_start", + "fastPreheat": "fast_preheat", + "scheduledEnd": "scheduled_end", + "stone_heating": "stone_heating", + "timeHoldPreheat": "time_hold_preheat", +} + MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } @@ -480,6 +488,27 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.OVEN_JOB_STATE, translation_key="oven_job_state", + options=[ + "cleaning", + "cooking", + "cooling", + "draining", + "preheat", + "ready", + "rinsing", + "finished", + "scheduled_start", + "warming", + "defrosting", + "sensing", + "searing", + "fast_preheat", + "scheduled_end", + "stone_heating", + "time_hold_preheat", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index b88c27fad77..5012cc9efa3 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -207,7 +207,26 @@ "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" }, "oven_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cleaning": "Cleaning", + "cooking": "Cooking", + "cooling": "Cooling", + "draining": "Draining", + "preheat": "Preheat", + "ready": "Ready", + "rinsing": "Rinsing", + "finished": "Finished", + "scheduled_start": "Scheduled start", + "warming": "Warming", + "defrosting": "Defrosting", + "sensing": "Sensing", + "searing": "Searing", + "fast_preheat": "Fast preheat", + "scheduled_end": "Scheduled end", + "stone_heating": "Stone heating", + "time_hold_preheat": "Time hold preheat" + } }, "oven_setpoint": { "name": "Set point" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 41691d26435..dde39d8b515 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1758,7 +1758,27 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1776,7 +1796,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -1790,7 +1810,27 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), }), 'context': , 'entity_id': 'sensor.microwave_job_state', From 51099ae7d67a3074c552433409148ffca9e16445 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:13:02 +0100 Subject: [PATCH 1306/1435] Add translatable states to oven machine state (#139358) --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 91b9a09fd19..c05dd546623 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -482,6 +482,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="oven_machine_state", + options=["ready", "running", "paused"], + device_class=SensorDeviceClass.ENUM, ) ], Attribute.OVEN_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 5012cc9efa3..897d07961bb 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -204,7 +204,12 @@ } }, "oven_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "ready": "Ready", + "running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "paused": "[%key:common::state::paused%]" + } }, "oven_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index dde39d8b515..1741e3ed2a1 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1845,7 +1845,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1863,7 +1869,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -1877,7 +1883,13 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), }), 'context': , 'entity_id': 'sensor.microwave_machine_state', From cadee73da869438aa3be3f7c48d0dbefb2d19525 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:25:50 +0100 Subject: [PATCH 1307/1435] Add translatable states to robot cleaner movement in SmartThings (#139363) --- .../components/smartthings/sensor.py | 18 +++++++++++ .../components/smartthings/strings.json | 14 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 30 +++++++++++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c05dd546623..c11ce51ceaa 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -61,6 +61,10 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +ROBOT_CLEANER_MOVEMENT_MAP = { + "powerOff": "off", +} + OVEN_MODE = { "Conventional": "conventional", "Bake": "bake", @@ -625,6 +629,20 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_MOVEMENT, translation_key="robot_cleaner_movement", + options=[ + "homing", + "idle", + "charging", + "alarm", + "off", + "reserve", + "point", + "after", + "cleaning", + "pause", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_MOVEMENT_MAP.get(value, value), ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 897d07961bb..a5335be616e 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -255,7 +255,19 @@ "name": "Cleaning mode" }, "robot_cleaner_movement": { - "name": "Movement" + "name": "Movement", + "state": { + "homing": "Homing", + "idle": "[%key:common::state::idle%]", + "charging": "[%key:common::state::charging%]", + "alarm": "Alarm", + "off": "[%key:common::state::off%]", + "reserve": "Reserve", + "point": "Point", + "after": "After", + "cleaning": "Cleaning", + "pause": "[%key:common::state::paused%]" + } }, "robot_cleaner_turbo_mode": { "name": "Turbo mode" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 1741e3ed2a1..4db096fdb22 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2563,7 +2563,20 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2581,7 +2594,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Movement', 'platform': 'smartthings', @@ -2595,7 +2608,20 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Movement', + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_movement', From 5e5fd6a2f2810896d1e63457d6ba2d67c915639f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:33:13 +0100 Subject: [PATCH 1308/1435] Add translatable states to robot cleaner cleaning mode in SmartThings (#139362) * Add translatable states to robot cleaner cleaning mode in SmartThings * Update homeassistant/components/smartthings/strings.json * Update homeassistant/components/smartthings/strings.json --------- Co-authored-by: Josef Zweck --- .../components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 10 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 22 +++++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c11ce51ceaa..f5c9fa823f0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -620,6 +620,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_CLEANING_MODE, translation_key="robot_cleaner_cleaning_mode", + options=["auto", "part", "repeat", "manual", "stop", "map"], + device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, ) ], diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index a5335be616e..0fdb705091d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -252,7 +252,15 @@ "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" }, "robot_cleaner_cleaning_mode": { - "name": "Cleaning mode" + "name": "Cleaning mode", + "state": { + "auto": "Auto", + "part": "Partial", + "repeat": "Repeat", + "manual": "Manual", + "stop": "[%key:common::action::stop%]", + "map": "Map" + } }, "robot_cleaner_movement": { "name": "Movement", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 4db096fdb22..22a67538098 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2516,7 +2516,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2534,7 +2543,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Cleaning mode', 'platform': 'smartthings', @@ -2548,7 +2557,16 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Cleaning mode', + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_cleaning_mode', From 92268f894a31b7d1e39009f198d36237a3882a06 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:34:29 +0100 Subject: [PATCH 1309/1435] Add translatable states to washer machine state in SmartThings (#139366) --- homeassistant/components/smartthings/sensor.py | 6 +++++- .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index f5c9fa823f0..65c48d5e0fe 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -93,6 +93,8 @@ OVEN_MODE = { "Rinse": "rinse", } +WASHER_OPTIONS = ["pause", "run", "stop"] + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -242,7 +244,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dishwasher_machine_state", - options=["pause", "run", "stop"], + options=WASHER_OPTIONS, device_class=SensorDeviceClass.ENUM, ) ], @@ -847,6 +849,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="washer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, ) ], Attribute.WASHER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0fdb705091d..6c14d5c2a4d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -326,7 +326,12 @@ "name": "Washer mode" }, "washer_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } }, "washer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 22a67538098..87fe69b9640 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3798,7 +3798,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3816,7 +3822,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -3830,7 +3836,13 @@ # name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Washer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.washer_machine_state', From 468208502f58fb271885431a4de57d985b66a52a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:52:57 +0100 Subject: [PATCH 1310/1435] Add translatable states to smoke detector in SmartThings (#139365) --- homeassistant/components/smartthings/sensor.py | 2 ++ homeassistant/components/smartthings/strings.json | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 65c48d5e0fe..c966899f8f9 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -684,6 +684,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.SMOKE, translation_key="smoke_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 6c14d5c2a4d..fb260d8f689 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -284,7 +284,12 @@ "name": "Link quality" }, "smoke_detector": { - "name": "Smoke detector" + "name": "Smoke detector", + "state": { + "detected": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::detected%]", + "clear": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::clear%]", + "tested": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::tested%]" + } }, "thermostat_cooling_setpoint": { "name": "Cooling set point" From 3eea932b240ea170734fac999df7c11e0c4b82f5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:53:16 +0100 Subject: [PATCH 1311/1435] Add translatable states to robot cleaner turbo mode in SmartThings (#139364) --- homeassistant/components/smartthings/sensor.py | 9 +++++++++ .../components/smartthings/strings.json | 8 +++++++- .../smartthings/snapshots/test_sensor.ambr | 18 ++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c966899f8f9..5e07112e677 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -61,6 +61,10 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +ROBOT_CLEANER_TURBO_MODE_STATE_MAP = { + "extraSilence": "extra_silence", +} + ROBOT_CLEANER_MOVEMENT_MAP = { "powerOff": "off", } @@ -655,6 +659,11 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_TURBO_MODE, translation_key="robot_cleaner_turbo_mode", + options=["on", "off", "silence", "extra_silence"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_TURBO_MODE_STATE_MAP.get( + value, value + ), entity_category=EntityCategory.DIAGNOSTIC, ) ] diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fb260d8f689..c17e63357ff 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -278,7 +278,13 @@ } }, "robot_cleaner_turbo_mode": { - "name": "Turbo mode" + "name": "Turbo mode", + "state": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]", + "silence": "Silent", + "extra_silence": "Extra silent" + } }, "link_quality": { "name": "Link quality" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 87fe69b9640..eecd801d062 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2654,7 +2654,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2672,7 +2679,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Turbo mode', 'platform': 'smartthings', @@ -2686,7 +2693,14 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Turbo mode', + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_turbo_mode', From 269482845150a4bea36ec9a3d221ccce6a835d4f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:07:56 +0100 Subject: [PATCH 1312/1435] Add translatable states to washer job state in SmartThings (#139368) * Add translatable states to washer job state in SmartThings * fix * Update homeassistant/components/smartthings/sensor.py --- .../components/smartthings/sensor.py | 30 +++++++++++- .../components/smartthings/strings.json | 22 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 46 +++++++++++++++++-- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 5e07112e677..e0fded8f801 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -43,6 +43,14 @@ THERMOSTAT_CAPABILITIES = { } JOB_STATE_MAP = { + "airWash": "air_wash", + "airwash": "air_wash", + "aIRinse": "ai_rinse", + "aISpin": "ai_spin", + "aIWash": "ai_wash", + "delayWash": "delay_wash", + "weightSensing": "weight_sensing", + "freezeProtection": "freeze_protection", "preDrain": "pre_drain", "preWash": "pre_wash", "wrinklePrevent": "wrinkle_prevent", @@ -257,7 +265,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.DISHWASHER_JOB_STATE, translation_key="dishwasher_job_state", options=[ - "airwash", + "air_wash", "cooling", "drying", "finish", @@ -868,6 +876,26 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.WASHER_JOB_STATE, translation_key="washer_job_state", + options=[ + "air_wash", + "ai_rinse", + "ai_spin", + "ai_wash", + "cooling", + "delay_wash", + "drying", + "finish", + "none", + "pre_wash", + "rinse", + "spin", + "wash", + "weight_sensing", + "wrinkle_prevent", + "freeze_protection", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index c17e63357ff..3130c618a2c 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -85,7 +85,7 @@ "dishwasher_job_state": { "name": "Job state", "state": { - "airwash": "Airwash", + "air_wash": "Air wash", "cooling": "Cooling", "drying": "Drying", "finish": "Finish", @@ -345,7 +345,25 @@ } }, "washer_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]", + "ai_rise": "AI rise", + "ai_spin": "AI spin", + "ai_wash": "AI wash", + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "Delay wash", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finish": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::finish%]", + "none": "None", + "pre_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::pre_wash%]", + "rinse": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::rinse%]", + "spin": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::spin%]", + "wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wash%]", + "weight_sensing": "Weight sensing", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "freeze_protection": "Freeze protection" + } } } } diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index eecd801d062..5531e520ec7 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2921,7 +2921,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'airwash', + 'air_wash', 'cooling', 'drying', 'finish', @@ -2967,7 +2967,7 @@ 'device_class': 'enum', 'friendly_name': 'Dishwasher Job state', 'options': list([ - 'airwash', + 'air_wash', 'cooling', 'drying', 'finish', @@ -3765,7 +3765,26 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3783,7 +3802,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -3797,7 +3816,26 @@ # name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Washer Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), }), 'context': , 'entity_id': 'sensor.washer_job_state', From 5be7f491469c7549be1c234e34dcb11dd14b4f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 26 Feb 2025 18:11:40 +0100 Subject: [PATCH 1313/1435] Improve Home Connect oven cavity temperature sensor (#139355) * Improve oven cavity temperature translation * Fetch cavity temperature unit * Handle generic Home Connect error * Improve test clarity --- .../components/home_connect/const.py | 9 ++ .../components/home_connect/number.py | 9 +- .../components/home_connect/sensor.py | 30 ++++++- .../components/home_connect/strings.json | 4 +- tests/components/home_connect/test_sensor.py | 83 +++++++++++++++++++ 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 692a5e91851..66c635f5d95 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -4,6 +4,8 @@ from typing import cast from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey +from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume + from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" @@ -21,6 +23,13 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) +UNIT_MAP = { + "seconds": UnitOfTime.SECONDS, + "ml": UnitOfVolume.MILLILITERS, + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, +} + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 404f063946c..cef35005b32 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -11,7 +11,6 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,6 +22,7 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, + UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity @@ -32,13 +32,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -UNIT_MAP = { - "seconds": UnitOfTime.SECONDS, - "ml": UnitOfVolume.MILLILITERS, - "°C": UnitOfTemperature.CELSIUS, - "°F": UnitOfTemperature.FAHRENHEIT, -} - NUMBERS = ( NumberEntityDescription( key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 3f85bc3404c..924744ded56 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,12 @@ """Provides a sensor for Home Connect.""" +import contextlib from dataclasses import dataclass from datetime import timedelta from typing import cast from aiohomeconnect.model import EventKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -23,6 +25,7 @@ from .const import ( BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, + UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity @@ -40,6 +43,7 @@ class HomeConnectSensorEntityDescription( default_value: str | None = None appliance_types: tuple[str, ...] | None = None + fetch_unit: bool = False BSH_PROGRAM_SENSORS = ( @@ -183,7 +187,8 @@ SENSORS = ( key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - translation_key="current_cavity_temperature", + translation_key="oven_current_cavity_temperature", + fetch_unit=True, ), ) @@ -318,6 +323,29 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): case _: self._attr_native_value = status + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.entity_description.fetch_unit: + data = self.appliance.status[cast(StatusKey, self.bsh_key)] + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + data.unit, data.unit + ) + else: + await self.fetch_unit() + + async def fetch_unit(self) -> None: + """Fetch the unit of measurement.""" + with contextlib.suppress(HomeConnectError): + data = await self.coordinator.client.get_status_value( + self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) + ) + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + data.unit, data.unit + ) + class HomeConnectProgramSensor(HomeConnectSensor): """Sensor class for Home Connect sensors that reports information related to the running program.""" diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 4fabd1e1c50..92b59919583 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1529,8 +1529,8 @@ "map3": "Map 3" } }, - "current_cavity_temperature": { - "name": "Current cavity temperature" + "oven_current_cavity_temperature": { + "name": "Current oven cavity temperature" }, "freezer_door_alarm": { "name": "Freezer door alarm", diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 1ec137b95be..31fc9ea6d3f 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfStatus, Event, EventKey, EventMessage, @@ -565,3 +566,85 @@ async def test_sensors_states( ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + "unit_get_status", + "unit_get_status_value", + "get_status_value_call_count", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + None, + 0, + ), + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + None, + "°C", + 1, + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + unit_get_status: str | None, + unit_get_status_value: str | None, + get_status_value_call_count: int, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + return_value=Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status_value, + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert ( + entity_state.attributes["unit_of_measurement"] == unit_get_status + or unit_get_status_value + ) + + assert client.get_status_value.call_count == get_status_value_call_count From 561b3ae21b2170d80ab70f3ee86bf994dec02e26 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:14:59 +0100 Subject: [PATCH 1314/1435] Add translatable states to dryer machine state in Smartthings (#139369) --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index e0fded8f801..8d53b830707 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -304,6 +304,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dryer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, ) ], Attribute.DRYER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 3130c618a2c..40e14fc1b51 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -104,7 +104,12 @@ "name": "Dryer mode" }, "dryer_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } }, "dryer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 5531e520ec7..122ced1eb6f 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3408,7 +3408,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3426,7 +3432,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -3440,7 +3446,13 @@ # name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dryer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.dryer_machine_state', From 25ee2e58a5a34e28a51c358cb8d5affcc9483e56 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:15:14 +0100 Subject: [PATCH 1315/1435] Add translatable states to dryer job state in SmartThings (#139370) * Add translatable states to washer job state in SmartThings * Add translatable states to dryer job state in Smartthings * fix * fix --- .../components/smartthings/sensor.py | 23 +++++++++++ .../components/smartthings/strings.json | 19 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 40 ++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 8d53b830707..d7aaaaa84c5 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -48,6 +48,10 @@ JOB_STATE_MAP = { "aIRinse": "ai_rinse", "aISpin": "ai_spin", "aIWash": "ai_wash", + "aIDrying": "ai_drying", + "internalCare": "internal_care", + "continuousDehumidifying": "continuous_dehumidifying", + "thawingFrozenInside": "thawing_frozen_inside", "delayWash": "delay_wash", "weightSensing": "weight_sensing", "freezeProtection": "freeze_protection", @@ -312,6 +316,25 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.DRYER_JOB_STATE, translation_key="dryer_job_state", + options=[ + "cooling", + "delay_wash", + "drying", + "finished", + "none", + "refreshing", + "weight_sensing", + "wrinkle_prevent", + "dehumidifying", + "ai_drying", + "sanitizing", + "internal_care", + "freeze_protection", + "continuous_dehumidifying", + "thawing_frozen_inside", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 40e14fc1b51..9a757b4e9e8 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -112,7 +112,24 @@ } }, "dryer_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::delay_wash%]", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]", + "none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]", + "refreshing": "Refreshing", + "weight_sensing": "[%key:component::smartthings::entity::sensor::washer_job_state::state::weight_sensing%]", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "dehumidifying": "Dehumidifying", + "ai_drying": "AI drying", + "sanitizing": "Sanitizing", + "internal_care": "Internal care", + "freeze_protection": "Freeze protection", + "continuous_dehumidifying": "Continuous dehumidifying", + "thawing_frozen_inside": "Thawing frozen inside" + } }, "equivalent_carbon_dioxide": { "name": "Equivalent carbon dioxide" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 122ced1eb6f..f487ff632a1 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3361,7 +3361,25 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3379,7 +3397,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -3393,7 +3411,25 @@ # name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dryer Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), }), 'context': , 'entity_id': 'sensor.dryer_job_state', From 3a21c3617377d5581fbf1f9e38eaa66f7f45ad13 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:19:28 +0100 Subject: [PATCH 1316/1435] Don't create entities for disabled capabilities in SmartThings (#139343) * Don't create entities for disabled capabilities in SmartThings * Fix * fix * fix --- .../components/smartthings/__init__.py | 28 +- .../smartthings/snapshots/test_cover.ambr | 49 -- .../smartthings/snapshots/test_sensor.ambr | 456 ------------------ 3 files changed, 26 insertions(+), 507 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d580e36e45e..846170552e9 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from aiohttp import ClientError from pysmartthings import ( @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: devices = await client.get_devices() for device in devices: - status = await client.get_device_status(device.device_id) + status = process_status(await client.get_device_status(device.device_id)) device_status[device.device_id] = FullDevice(device=device, status=status) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err @@ -143,3 +143,27 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return True + + +def process_status( + status: dict[str, dict[Capability, dict[Attribute, Status]]], +) -> dict[str, dict[Capability, dict[Attribute, Status]]]: + """Remove disabled capabilities from status.""" + if (main_component := status.get("main")) is None or ( + disabled_capabilities_capability := main_component.get( + Capability.CUSTOM_DISABLED_CAPABILITIES + ) + ) is None: + return status + disabled_capabilities = cast( + list[Capability], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + for capability in disabled_capabilities: + # We still need to make sure the climate entity can work without this capability + if ( + capability in main_component + and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + ): + del main_component[capability] + return status diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 102be416cea..aa928c09b7a 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -49,52 +49,3 @@ 'state': 'open', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.microwave', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Microwave', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.microwave', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index f487ff632a1..778b05fa183 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -521,57 +521,6 @@ 'state': '15.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_air_quality', - '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': 'Air quality', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', - 'unit_of_measurement': 'CAQI', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Air quality', - 'state_class': , - 'unit_of_measurement': 'CAQI', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_air_quality', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -780,110 +729,6 @@ 'state': '60', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_pm10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM10', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm10', - 'friendly_name': 'AC Office Granit PM10', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_pm2_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM2.5', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm25', - 'friendly_name': 'AC Office Granit PM2.5', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1090,57 +935,6 @@ 'state': '100', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - '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': 'Air quality', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', - 'unit_of_measurement': 'CAQI', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Air quality', - 'state_class': , - 'unit_of_measurement': 'CAQI', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1349,157 +1143,6 @@ 'state': '42', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - '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': 'Odor sensor', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'odor_sensor', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Odor sensor', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_pm10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM10', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm10', - 'friendly_name': 'Aire Dormitorio Principal PM10', - 'state_class': , - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM2.5', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm25', - 'friendly_name': 'Aire Dormitorio Principal PM2.5', - 'state_class': , - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2101,54 +1744,6 @@ 'state': '-17', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.refrigerator_cooling_set_point', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cooling set point', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Cooling set point', - }), - 'context': , - 'entity_id': 'sensor.refrigerator_cooling_set_point', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2411,57 +2006,6 @@ 'state': '0.0135559777781698', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.refrigerator_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.temperature', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Temperature', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.refrigerator_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2e972422c29fefe7bda97476eb87b0d931df9b8c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:19:45 +0100 Subject: [PATCH 1317/1435] Fix typo in SmartThing string (#139373) --- homeassistant/components/smartthings/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9a757b4e9e8..e5ffbe35e8b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -370,7 +370,7 @@ "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", "state": { "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]", - "ai_rise": "AI rise", + "ai_rinse": "AI rinse", "ai_spin": "AI spin", "ai_wash": "AI wash", "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", From 693584ce291b6a5272d381f6e081b38fcc3e7e1f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 18:23:01 +0100 Subject: [PATCH 1318/1435] Bump version to 2025.3.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 7775b618795..00a9cf3b25f 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 = 3 -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 a7e3917eb90..e5f5884945a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0.dev0" +version = "2025.3.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0e1602ff7135814b6ba32b6733a83411e0a8626a Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 26 Feb 2025 20:31:24 +0100 Subject: [PATCH 1319/1435] Bump stookwijzer==1.6.1 (#139380) --- homeassistant/components/stookwijzer/__init__.py | 8 ++++---- homeassistant/components/stookwijzer/config_flow.py | 6 +++--- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 8 ++++---- tests/components/stookwijzer/test_config_flow.py | 6 +++--- tests/components/stookwijzer/test_init.py | 6 +++--- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index a4a00e4d1b8..9adfc09de0e 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -42,12 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not longitude or not latitude: + if not xy: ir.async_create_issue( hass, DOMAIN, @@ -65,8 +65,8 @@ async def async_migrate_entry( entry, version=2, data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, + CONF_LATITUDE: xy["x"], + CONF_LONGITUDE: xy["y"], }, ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 52283e4842d..ff14bce26e6 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if longitude and latitude: + if xy: return self.async_create_entry( title="Stookwijzer", - data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]}, ) errors["base"] = "unknown" diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 9b4cea567be..dd10f57f485 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.6.0"] + "requirements": ["stookwijzer==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 082524036e8..dcda559d7d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2808,7 +2808,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cac6cc79d0..5ed82bd81b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2269,7 +2269,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 95a60e623a3..40582dc4be3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -70,10 +70,10 @@ def mock_stookwijzer() -> Generator[MagicMock]: new=stookwijzer_mock, ), ): - stookwijzer_mock.async_transform_coordinates.return_value = ( - 450000.123456789, - 200000.123456789, - ) + stookwijzer_mock.async_transform_coordinates.return_value = { + "x": 450000.123456789, + "y": 200000.123456789, + } client = stookwijzer_mock.return_value client.lki = 2 diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index 6dddf83c27a..060d2bdc26c 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -32,8 +32,8 @@ async def test_full_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Stookwijzer" assert result["data"] == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } assert len(mock_setup_entry.mock_calls) == 1 @@ -47,7 +47,7 @@ async def test_connection_error( ) -> None: """Test user configuration flow while connection fails.""" original_return_value = mock_stookwijzer.async_transform_coordinates.return_value - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index ddefb6be772..4306b9afc26 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -66,8 +66,8 @@ async def test_migrate_entry( assert mock_v1_config_entry.version == 2 assert mock_v1_config_entry.data == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } @@ -81,7 +81,7 @@ async def test_entry_migration_failure( assert mock_v1_config_entry.version == 1 # Failed getting the transformed coordinates - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None mock_v1_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) From 3effc2e182d33f693d6ba96d0726c353c79e5d4d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:22:08 -0500 Subject: [PATCH 1320/1435] Bump ZHA to 0.0.51 (#139383) * Bump ZHA to 0.0.51 * Fix unit tests not accounting for primary entities --- homeassistant/components/zha/entity.py | 4 ++++ homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 11 +--------- tests/components/zha/test_sensor.py | 21 ++++++++++++------- tests/components/zha/test_websocket_api.py | 7 +++++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 499721722fa..e3339661d15 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" meta = self.entity_data.entity.info_object + if meta.primary: + self._attr_name = None + return super().name + original_name = super().name if original_name not in (UNDEFINED, None) or meta.fallback_name is None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 25e4de77a32..0cc2524469e 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.50"], + "requirements": ["zha==0.0.51"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index dcda559d7d3..1a7c37a6cd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ed82bd81b0..dea6769aa39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 7a599b00a21..ba8aa9ea245 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,16 +179,7 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': list([ - 50, - 79, - 50, - 2, - 0, - 141, - 21, - 0, - ]), + 'value': None, }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 2d69cf1ff36..88fb9974c1b 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant from .common import send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +ENTITY_ID_NO_PREFIX = "sensor.fakemanufacturer_fakemodel" ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -335,7 +336,7 @@ async def async_test_pi_heating_demand( "humidity", async_test_humidity, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -344,7 +345,7 @@ async def async_test_pi_heating_demand( "temperature", async_test_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -353,7 +354,7 @@ async def async_test_pi_heating_demand( "pressure", async_test_pressure, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -362,7 +363,7 @@ async def async_test_pi_heating_demand( "illuminance", async_test_illuminance, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -492,7 +493,7 @@ async def async_test_pi_heating_demand( "device_temperature", async_test_device_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -501,7 +502,7 @@ async def async_test_pi_heating_demand( "setpoint_change_source", async_test_setpoint_change_source, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -510,7 +511,7 @@ async def async_test_pi_heating_demand( "pi_heating_demand", async_test_pi_heating_demand, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -558,7 +559,6 @@ async def test_sensor( gateway.get_or_create_device(zigpy_device) await gateway.async_device_initialized(zigpy_device) await hass.async_block_till_done(wait_background_tasks=True) - entity_id = ENTITY_ID_PREFIX.format(entity_suffix) zigpy_device = zigpy_device_mock( { @@ -570,6 +570,11 @@ async def test_sensor( } ) + if hass.states.get(ENTITY_ID_NO_PREFIX): + entity_id = ENTITY_ID_NO_PREFIX + else: + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) + assert hass.states.get(entity_id).state == initial_sensor_state # test sensor associated logic diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index f6afee9eb83..ae1ea90d1f9 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -420,8 +420,11 @@ async def test_list_groupable_devices( assert entity_reference[ATTR_NAME] is not None assert entity_reference["entity_id"] is not None - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None + if len(endpoint["entities"]) == 1: + assert endpoint["entities"][0]["original_name"] is None + else: + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None # Make sure there are no groupable devices when the device is unavailable # Make device unavailable From 585b950a467a65f16f4751aef127f603a39b5576 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Feb 2025 14:14:03 -0600 Subject: [PATCH 1321/1435] Bump intents to 2025.2.26 (#139387) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2d4a8053d75..c4f1860eed6 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.3", "home-assistant-intents==2025.2.5"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b248be0eb96..c49580ae47b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250226.0 -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 1a7c37a6cd3..4580eac890d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dea6769aa39..10365827696 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index b2e4005cf79..1f177643bd5 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ - 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 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From fa6d7d5e3c644ace1b2f88624ddbad390bea5b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 12:00:14 +0100 Subject: [PATCH 1322/1435] Fix fetch options error for Home connect (#139392) * Handle errors when obtaining options definitions * Don't fetch program options if the program key is unknown * Test to ensure that available program endpoint is not called on unknown program --- .../components/home_connect/coordinator.py | 31 +++++--- .../home_connect/test_coordinator.py | 22 +++++- tests/components/home_connect/test_entity.py | 73 +++++++++++++++++++ 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 80ae8173d86..d9200b282c9 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -440,13 +440,27 @@ class HomeConnectCoordinator( self, ha_id: str, program_key: ProgramKey ) -> dict[OptionKey, ProgramDefinitionOption]: """Get options with constraints for appliance.""" - return { - option.key: option - for option in ( - await self.client.get_available_program(ha_id, program_key=program_key) - ).options - or [] - } + if program_key is ProgramKey.UNKNOWN: + return {} + try: + return { + option.key: option + for option in ( + await self.client.get_available_program( + ha_id, program_key=program_key + ) + ).options + or [] + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching options for %s: %s", + ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return {} async def update_options( self, ha_id: str, event_key: EventKey, program_key: ProgramKey @@ -456,8 +470,7 @@ class HomeConnectCoordinator( events = self.data[ha_id].events options_to_notify = options.copy() options.clear() - if program_key is not ProgramKey.UNKNOWN: - options.update(await self.get_options_definitions(ha_id, program_key)) + options.update(await self.get_options_definitions(ha_id, program_key)) for option in options.values(): option_value = option.constraints.default if option.constraints else None diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 51f42a98f42..3dd9ffbe7c1 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -75,21 +75,35 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY -async def test_coordinator_update_failing_get_settings_status( +@pytest.mark.parametrize( + "mock_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_coordinator_update_failing( + mock_method: str, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - client_with_exception: MagicMock, + client: MagicMock, ) -> None: """Test that although is not possible to get settings and status, the config entry is loaded. This is for cases where some appliances are reachable and some are not in the same configuration entry. """ - # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError())) + assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client_with_exception) + await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + getattr(client, mock_method).assert_called() + @pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index f173cda0b0c..6ac9a2c1d90 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -23,6 +23,7 @@ from aiohomeconnect.model.error import ( SelectedProgramNotSetError, ) from aiohomeconnect.model.program import ( + EnumerateProgram, ProgramDefinitionConstraints, ProgramDefinitionOption, ) @@ -234,6 +235,78 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +async def test_no_options_retrieval_on_unknown_program( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that no options are retrieved when the program is unknown.""" + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + return ArrayOfPrograms( + **( + { + "programs": [ + EnumerateProgram(ProgramKey.UNKNOWN, "unknown program") + ], + array_of_programs_program_arg: Program( + ProgramKey.UNKNOWN, options=[] + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_available_program.call_count == 0 + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert client.get_available_program.call_count == 0 + + @pytest.mark.parametrize( "event_key", [ From 0c084305073b1d0959b71f4d2210e018dd5d1833 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:00:50 +0100 Subject: [PATCH 1323/1435] Bump onedrive to 0.0.12 (#139410) * Bump onedrive to 0.0.12 * Add alternative name --- homeassistant/components/onedrive/manifest.json | 2 +- homeassistant/components/onedrive/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 5ab16402cb8..31a1f2ccb06 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": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.11"] + "requirements": ["onedrive-personal-sdk==0.0.12"] } diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py index 0ca2b166e3f..fa7c0b125fe 100644 --- a/homeassistant/components/onedrive/sensor.py +++ b/homeassistant/components/onedrive/sensor.py @@ -103,7 +103,7 @@ class OneDriveDriveStateSensor( self._attr_unique_id = f"{coordinator.data.id}_{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=coordinator.data.name, + name=coordinator.data.name or coordinator.config_entry.title, identifiers={(DOMAIN, coordinator.data.id)}, manufacturer="Microsoft", model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", diff --git a/requirements_all.txt b/requirements_all.txt index 4580eac890d..577e1cdc578 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10365827696..593ff9203cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 2cde317d59f8c5fd14daa00be05f294765966803 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 13:25:55 +0100 Subject: [PATCH 1324/1435] Bump pysmartthings to 2.0.0 (#139418) * Bump pysmartthings to 2.0.0 * Fix * Fix * Fix * Fix --- .../components/smartthings/__init__.py | 8 +++--- homeassistant/components/smartthings/cover.py | 2 +- .../components/smartthings/entity.py | 13 +++++++-- .../components/smartthings/manifest.json | 2 +- .../components/smartthings/sensor.py | 28 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/__init__.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 8 +++--- tests/components/smartthings/test_sensor.py | 14 +++++----- 10 files changed, 50 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 846170552e9..4bc9b270360 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -46,7 +46,7 @@ class FullDevice: """Define an object to hold device data.""" device: Device - status: dict[str, dict[Capability, dict[Attribute, Status]]] + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]] type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] @@ -146,8 +146,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def process_status( - status: dict[str, dict[Capability, dict[Attribute, Status]]], -) -> dict[str, dict[Capability, dict[Attribute, Status]]]: + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], +) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: """Remove disabled capabilities from status.""" if (main_component := status.get("main")) is None or ( disabled_capabilities_capability := main_component.get( @@ -156,7 +156,7 @@ def process_status( ) is None: return status disabled_capabilities = cast( - list[Capability], + list[Capability | str], disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, ) for capability in disabled_capabilities: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index fd4752b4e28..0b0f03679eb 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover(entry_data.client, device, capability) + SmartThingsCover(entry_data.client, device, Capability(capability)) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index b2e556c6718..1383196ce15 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -4,7 +4,14 @@ from __future__ import annotations from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings +from pysmartthings import ( + Attribute, + Capability, + Command, + DeviceEvent, + SmartThings, + Status, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -25,7 +32,7 @@ class SmartThingsEntity(Entity): """Initialize the instance.""" self.client = client self.capabilities = capabilities - self._internal_state = { + self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { capability: device.status[MAIN][capability] for capability in capabilities if capability in device.status[MAIN] @@ -58,7 +65,7 @@ class SmartThingsEntity(Entity): await super().async_added_to_hass() for capability in self._internal_state: self.async_on_remove( - self.client.add_device_event_listener( + self.client.add_device_capability_event_listener( self.device.device.device_id, MAIN, capability, diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index b34ab90ca8c..c5277241aa4 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==1.2.0"] + "requirements": ["pysmartthings==2.0.0"] } diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d7aaaaa84c5..bc986894045 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -130,6 +130,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + except_if_state_none: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -579,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -587,6 +589,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -595,6 +598,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -603,6 +607,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -611,6 +616,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + except_if_state_none=True, ), ] }, @@ -953,14 +959,20 @@ async def async_setup_entry( async_add_entities( SmartThingsSensor(entry_data.client, device, description, capability, attribute) for device in entry_data.devices.values() - for capability, attributes in device.status[MAIN].items() - if capability in CAPABILITY_TO_SENSORS - for attribute in attributes - for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) - if not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list + for capability, attributes in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, descriptions in attributes.items() + for description in descriptions + if ( + not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) + ) + and ( + not description.except_if_state_none + or device.status[MAIN][capability][attribute].value is not None ) ) diff --git a/requirements_all.txt b/requirements_all.txt index 577e1cdc578..d4b57e0a2ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 593ff9203cc..0940b6ceef9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 94a2e7512f2..a5e51c7d434 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,7 +57,7 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" - for call in mock.add_device_event_listener.call_args_list: + for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: call[0][3]( DeviceEvent( diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 778b05fa183..93a683afe82 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,7 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -35,7 +35,7 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -44,7 +44,7 @@ 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 8b8bb8930f4..c83950de9e9 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -28,7 +28,7 @@ async def test_all_entities( snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_state_update( hass: HomeAssistant, devices: AsyncMock, @@ -37,15 +37,15 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" await trigger_update( hass, devices, - "f0af21a2-d5a1-437c-b10a-b34a87394b71", - Capability.ENERGY_METER, - Attribute.ENERGY, - 20000.0, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, ) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" From 7732e6878ed1b8fdd50ef07ff012f8393a5e443a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 11:41:27 +0000 Subject: [PATCH 1325/1435] Bump habluetooth to 3.24.1 (#139420) --- 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 8eeb4d67109..6c851e603d9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.23.4", "dbus-fast==2.33.0", - "habluetooth==3.24.0" + "habluetooth==3.24.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c49580ae47b..012206d2833 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.33.0 fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.24.0 +habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index d4b57e0a2ac..1114642c71f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0940b6ceef9..1d94a856ee8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 From 59d92c75bd7542d62eca243a96791ee103894be8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Feb 2025 14:51:40 +0000 Subject: [PATCH 1326/1435] Fix conversation agent fallback (#139421) --- .../components/assist_pipeline/pipeline.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 788a207b83a..75811a0ec36 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1103,12 +1103,16 @@ class PipelineRun: ) & conversation.ConversationEntityFeature.CONTROL: intent_filter = _async_local_fallback_intent_filter - # Try local intents first, if preferred. - elif self.pipeline.prefer_local_intents and ( - intent_response := await conversation.async_handle_intents( - self.hass, - user_input, - intent_filter=intent_filter, + # Try local intents + if ( + intent_response is None + and self.pipeline.prefer_local_intents + and ( + intent_response := await conversation.async_handle_intents( + self.hass, + user_input, + intent_filter=intent_filter, + ) ) ): # Local intent matched From 6a1bbdb3a71bb40bd6d689b72da2d8418fe56c15 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 15:23:25 +0100 Subject: [PATCH 1327/1435] Add diagnostics to SmartThings (#139423) --- .../components/smartthings/diagnostics.py | 50 + tests/components/smartthings/__init__.py | 28 +- .../snapshots/test_diagnostics.ambr | 1163 +++++++++++++++++ .../smartthings/test_diagnostics.py | 44 + 4 files changed, 1272 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/smartthings/diagnostics.py create mode 100644 tests/components/smartthings/snapshots/test_diagnostics.ambr create mode 100644 tests/components/smartthings/test_diagnostics.py diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py new file mode 100644 index 00000000000..bcf40645d22 --- /dev/null +++ b/homeassistant/components/smartthings/diagnostics.py @@ -0,0 +1,50 @@ +"""Diagnostics support for SmartThings.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from typing import Any + +from pysmartthings import DeviceEvent + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import SmartThingsConfigEntry +from .const import DOMAIN + +EVENT_WAIT_TIME = 5 + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + device_id = next( + identifier for identifier in device.identifiers if identifier[0] == DOMAIN + )[0] + + events: list[DeviceEvent] = [] + + def register_event(event: DeviceEvent) -> None: + events.append(event) + + client = entry.runtime_data.client + + listener = client.add_device_event_listener(device_id, register_event) + + await asyncio.sleep(EVENT_WAIT_TIME) + + listener() + + device_status = await client.get_device_status(device_id) + + status: dict[str, Any] = {} + for component, capabilities in device_status.items(): + status[component] = {} + for capability, attributes in capabilities.items(): + status[component][capability] = {} + for attribute, value in attributes.items(): + status[component][capability][attribute] = asdict(value) + return {"events": [asdict(event) for event in events], "status": status} diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index a5e51c7d434..6939d3c5dcc 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,19 +57,21 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" + event = DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][3](event) for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: - call[0][3]( - DeviceEvent( - "abc", - "abc", - "abc", - device_id, - MAIN, - capability, - attribute, - value, - data, - ) - ) + call[0][3](event) await hass.async_block_till_done() diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..50f568df5d1 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -0,0 +1,1163 @@ +# serializer version: 1 +# name: test_device[da_ac_rac_000001] + dict({ + 'events': list([ + ]), + 'status': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.381000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.602000+00:00', + 'unit': 'CAQI', + 'value': None, + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.541000+00:00', + 'unit': '%', + 'value': None, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.498000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.344000+00:00', + 'unit': None, + 'value': None, + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtime': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.843000+00:00', + 'unit': None, + 'value': None, + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.686000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:54.748000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'dustSensor': dict({ + 'dustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.247000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.325000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'odorSensor': dict({ + 'odorLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.992000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.364000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.291000+00:00', + 'unit': '%', + 'value': 0, + }), + }), + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.097000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.518000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.373000+00:00', + 'unit': None, + 'value': None, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:59.136000+00:00', + 'unit': None, + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.529000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + }), + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + '1', + ]), + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': '2021-12-29T07:29:17.526000+00:00', + 'unit': None, + 'value': 'False', + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2025-01-08T06:30:58.307000+00:00', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', + }), + }), + }), + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270000+00:00', + 'unit': None, + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.782000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.912000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.803000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.933000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'DA-AC-RAC-000001', + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:15:33.639000+00:00', + 'unit': None, + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), + }), + 'refresh': dict({ + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2024-12-30T13:10:23.759000+00:00', + 'unit': '%', + 'value': 60, + }), + }), + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'micomAssayCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelClassificationCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelName': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'releaseYear': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumber': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumberExtra': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': 24070101, + }), + }), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.349000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'result': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'status': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.549000+00:00', + 'unit': None, + 'value': 'ready', + }), + 'supportedActions': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': list([ + 'start', + ]), + }), + }), + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'newVersionAvailable': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'False', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'otnDUID': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'targetModule': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:37:54.072000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:33:29.164000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:15:11.608000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py new file mode 100644 index 00000000000..22f1c77cdd1 --- /dev/null +++ b/tests/components/smartthings/test_diagnostics.py @@ -0,0 +1,44 @@ +"""Test SmartThings diagnostics.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_device +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} + ) + + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): + diag = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + + assert diag == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) From 553abe4a4aad63d3add6569a7bbd302a30557391 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 12:22:43 +0000 Subject: [PATCH 1328/1435] Bump bleak-esphome to 2.8.0 (#139426) --- 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 0bc3ae55236..18dcbb5cb65 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.7.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b59dd544c49..d07754d68a0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.2.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.7.1" + "bleak-esphome==2.8.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 1114642c71f..828c16d3be5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d94a856ee8..40f4d8e6480 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From 16314711b89054e46685931e3b602a6f1e734b7e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 14:29:57 +0100 Subject: [PATCH 1329/1435] Bump reolink-aio to 0.12.1 (#139427) --- 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 37e448aa820..f923efdbbf2 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.12.0"] + "requirements": ["reolink-aio==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 828c16d3be5..05512f945a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2618,7 +2618,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40f4d8e6480..21508fb5a4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2121,7 +2121,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.rflink rflink==0.0.66 From 381fa65ba03cbc858e5ad299d3e685ac1f32b6b1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 27 Feb 2025 14:30:29 +0100 Subject: [PATCH 1330/1435] Fix Music Assistant media player entity features (#139428) * Fix Music Assistant supported media player features * Update supported features when player config changes * Add tests --- .../music_assistant/media_player.py | 46 +++++-- tests/components/music_assistant/common.py | 39 +++++- .../music_assistant/test_media_player.py | 116 +++++++++++++++++- 3 files changed, 182 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index bbbda095302..c079fd20e91 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -9,6 +9,7 @@ import functools import os from typing import TYPE_CHECKING, Any, Concatenate +from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, MediaType, @@ -80,19 +81,14 @@ if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player -SUPPORTED_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.STOP +SUPPORTED_FEATURES_BASE = ( + MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.MEDIA_ENQUEUE @@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Initialize MediaPlayer entity.""" super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") - self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SET_MEMBERS in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING - if PlayerFeature.VOLUME_MUTE in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) ) + # we subscribe to the player config changed event to update + # the supported features of the player + async def player_config_changed(event: MassEvent) -> None: + self._set_supported_features() + await self.async_on_update() + self.async_write_ha_state() + + self.async_on_remove( + self.mass.subscribe( + player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id + ) + ) + @property def active_queue(self) -> PlayerQueue | None: """Return the active queue for this player (if any).""" @@ -682,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if isinstance(queue_option, MediaPlayerEnqueue): queue_option = QUEUE_OPTION_MAP.get(queue_option) return queue_option + + def _set_supported_features(self) -> None: + """Set supported features based on player capabilities.""" + supported_features = SUPPORTED_FEATURES_BASE + if PlayerFeature.SET_MEMBERS in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.GROUPING + if PlayerFeature.PAUSE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.PAUSE + if self.player.mute_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.player.volume_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_STEP + supported_features |= MediaPlayerEntityFeature.VOLUME_SET + if self.player.power_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.TURN_ON + supported_features |= MediaPlayerEntityFeature.TURN_OFF + self._attr_supported_features = supported_features diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 7c0f9df751a..863d945ccd1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,9 +2,11 @@ from __future__ import annotations +import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from music_assistant_models.player import Player @@ -134,15 +136,42 @@ async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, event: EventType = EventType.PLAYER_UPDATED, + object_id: str | None = None, data: Any = None, ) -> None: """Trigger a subscription callback.""" # trigger callback on all subscribers - for sub in client.subscribe_events.call_args_list: - callback = sub.kwargs["callback"] - event_filter = sub.kwargs.get("event_filter") - if event_filter in (None, event): - callback(event, data) + for sub in client.subscribe.call_args_list: + cb_func = sub.kwargs.get("cb_func", sub.args[0]) + event_filter = sub.kwargs.get( + "event_filter", sub.args[1] if len(sub.args) > 1 else None + ) + id_filter = sub.kwargs.get( + "id_filter", sub.args[2] if len(sub.args) > 2 else None + ) + if not ( + event_filter is None + or event == event_filter + or (isinstance(event_filter, list) and event in event_filter) + ): + continue + if not ( + id_filter is None + or object_id == id_filter + or (isinstance(id_filter, list) and object_id in id_filter) + ): + continue + + event = MassEvent( + event=event, + object_id=object_id, + data=data, + ) + if asyncio.iscoroutinefunction(cb_func): + await cb_func(event) + else: + cb_func(event) + await hass.async_block_till_done() diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 25dfcd22c72..44317d4977a 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock, call -from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + EventType, + MediaType, + PlayerFeature, + QueueOption, +) from music_assistant_models.media_items import Track import pytest from syrupy import SnapshotAssertion @@ -20,6 +26,7 @@ from homeassistant.components.media_player import ( SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_UNJOIN, + MediaPlayerEntityFeature, ) from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN from homeassistant.components.music_assistant.media_player import ( @@ -59,7 +66,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) from tests.common import AsyncMock @@ -607,3 +618,104 @@ async def test_media_player_get_queue_action( # no call is made, this info comes from the cached queue data assert music_assistant_client.send_command.call_count == 0 assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time")) + + +async def test_media_player_supported_features( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test if media_player entity supported features are cortrectly (re)mapped.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + expected_features = ( + MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + ) + assert state.attributes["supported_features"] == expected_features + # remove power control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].power_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.TURN_ON + expected_features &= ~MediaPlayerEntityFeature.TURN_OFF + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove volume control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].volume_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET + expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove mute control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].mute_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove pause capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.PAUSE + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.PAUSE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove grouping capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.SET_MEMBERS + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.GROUPING + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features From e4200a79a25f78e747fc0f3eacfe431248262de0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Feb 2025 16:42:08 +0100 Subject: [PATCH 1331/1435] Update frontend to 20250227.0 (#139437) --- 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 7bd361041e1..5399b22f075 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250226.0"] + "requirements": ["home-assistant-frontend==20250227.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 012206d2833..b8e0b417353 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 05512f945a4..a6eb357230d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21508fb5a4a..7049fd84d84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 345ba73777c3284eec592e497abc18c086efe4f1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Feb 2025 16:48:00 +0100 Subject: [PATCH 1332/1435] Bump version to 2025.3.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 00a9cf3b25f..f22037b9e1d 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 = 3 -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 e5f5884945a..464b236353f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b0" +version = "2025.3.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c5e5fe555d929655a3852fae5b52ed6ac024dced Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 28 Feb 2025 06:32:52 -0700 Subject: [PATCH 1333/1435] Bump weatherflow4py to 1.3.1 (#135529) * version bump of dep * update requirements --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 98c98cfbac7..9ffa457a355 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.0.6"] + "requirements": ["weatherflow4py==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6eb357230d..0e47f66c965 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3046,7 +3046,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7049fd84d84..642494927a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2453,7 +2453,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.nasweb webio-api==0.1.11 From 83c035133854908f7d4c4576a6fe1597fd51de1f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 14:17:02 +0100 Subject: [PATCH 1334/1435] Add new mediatypes to Music Assistant integration (#139338) * Bump Music Assistant client to 1.1.0 * Add some casts to help mypy * Add handling of the new media types in Music Assistant * mypy cleanup * lint * update snapshot * Adjust tests --------- Co-authored-by: Franck Nijhof --- .../components/music_assistant/actions.py | 36 +- .../components/music_assistant/const.py | 2 + .../components/music_assistant/schemas.py | 8 + .../components/music_assistant/services.yaml | 7 + .../components/music_assistant/strings.json | 3 + tests/components/music_assistant/common.py | 26 +- .../fixtures/library_audiobooks.json | 489 ++++++++++++++++++ .../fixtures/library_podcasts.json | 309 +++++++++++ .../snapshots/test_actions.ambr | 196 ++++++- .../music_assistant/test_actions.py | 16 +- 10 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 tests/components/music_assistant/fixtures/library_audiobooks.json create mode 100644 tests/components/music_assistant/fixtures/library_podcasts.json diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bf9a1260362..031229d1544 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -23,6 +23,7 @@ from .const import ( ATTR_ALBUM_TYPE, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, @@ -32,6 +33,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_RADIO, ATTR_SEARCH, ATTR_SEARCH_ALBUM, @@ -48,7 +50,15 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient - from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track + from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, + ) from . import MusicAssistantConfigEntry @@ -155,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_item_dict_from_mass_item(mass, item) for item in search_results.radio ], + ATTR_AUDIOBOOKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.audiobooks + ], + ATTR_PODCASTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.podcasts + ], } ) return response @@ -175,7 +193,13 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "order_by": order_by, } library_result: ( - list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + list[Album] + | list[Artist] + | list[Track] + | list[Radio] + | list[Playlist] + | list[Audiobook] + | list[Podcast] ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( @@ -199,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: library_result = await mass.music.get_library_playlists( **base_params, ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) else: raise ServiceValidationError(f"Unsupported media type {media_type}") diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 1980c495278..d2ee1f75028 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists" ATTR_ALBUMS = "albums" ATTR_TRACKS = "tracks" ATTR_PLAYLISTS = "playlists" +ATTR_AUDIOBOOKS = "audiobooks" +ATTR_PODCASTS = "podcasts" ATTR_RADIO = "radio" ATTR_ITEMS = "items" ATTR_RADIO_MODE = "radio_mode" diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 0954d1573e7..7501d3d2038 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -15,6 +15,7 @@ from .const import ( ATTR_ALBUM, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_BIT_DEPTH, ATTR_CONTENT_TYPE, ATTR_CURRENT_INDEX, @@ -31,6 +32,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_PROVIDER, ATTR_QUEUE_ID, ATTR_QUEUE_ITEM_ID, @@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema( vol.Required(ATTR_RADIO): vol.All( cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] ), + vol.Required(ATTR_AUDIOBOOKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PODCASTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), }, ) diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 73e8e2d7521..a3715ea2580 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -21,7 +21,10 @@ play_media: options: - artist - album + - audiobook + - folder - playlist + - podcast - track - radio artist: @@ -118,7 +121,9 @@ search: options: - artist - album + - audiobook - playlist + - podcast - track - radio artist: @@ -160,7 +165,9 @@ get_library: options: - artist - album + - audiobook - playlist + - podcast - track - radio favorite: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 32b72088518..7338af7cb65 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -195,8 +195,11 @@ "options": { "artist": "Artist", "album": "Album", + "audiobook": "Audiobook", + "folder": "Folder", "track": "Track", "playlist": "Playlist", + "podcast": "Podcast", "radio": "Radio" } }, diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 863d945ccd1..6d7ef927c6e 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -8,7 +8,15 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType -from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, +) from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from syrupy import SnapshotAssertion @@ -62,6 +70,10 @@ async def setup_integration_from_fixtures( music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks) library_radios = create_library_radios_from_fixture() music.get_library_radios = AsyncMock(return_value=library_radios) + library_audiobooks = create_library_audiobooks_from_fixture() + music.get_library_audiobooks = AsyncMock(return_value=library_audiobooks) + library_podcasts = create_library_podcasts_from_fixture() + music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() config_entry.add_to_hass(hass) @@ -132,6 +144,18 @@ def create_library_radios_from_fixture() -> list[Radio]: return [Radio.from_dict(radio_data) for radio_data in fixture_data] +def create_library_audiobooks_from_fixture() -> list[Audiobook]: + """Create MA Audiobooks from fixture.""" + fixture_data = load_and_parse_fixture("library_audiobooks") + return [Audiobook.from_dict(radio_data) for radio_data in fixture_data] + + +def create_library_podcasts_from_fixture() -> list[Podcast]: + """Create MA Podcasts from fixture.""" + fixture_data = load_and_parse_fixture("library_podcasts") + return [Podcast.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/library_audiobooks.json b/tests/components/music_assistant/fixtures/library_audiobooks.json new file mode 100644 index 00000000000..1994ee68e14 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_audiobooks.json @@ -0,0 +1,489 @@ +{ + "library_audiobooks": [ + { + "item_id": "1", + "provider": "library", + "name": "Test Audiobook", + "version": "", + "sort_name": "test audiobook", + "uri": "library://audiobook/1", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "test-audiobook.mp3", + "provider_domain": "filesystem_smb", + "provider_instance": "filesystem_smb--7Kf8QySu", + "available": true, + "audio_format": { + "content_type": "mp3", + "codec_type": "?", + "sample_rate": 48000, + "bit_depth": 16, + "channels": 1, + "output_format_str": "mp3", + "bit_rate": 90304 + }, + "url": null, + "details": "1738502411" + } + ], + "metadata": { + "description": "Cover (front)", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "test-audiobook.mp3", + "provider": "filesystem_smb--7Kf8QySu", + "remotely_accessible": false + } + ], + "genres": [], + "mood": null, + "style": null, + "copyright": null, + "lyrics": "", + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": null, + "authors": ["TestWriter"], + "narrators": [], + "duration": 9, + "fully_played": true, + "resume_position_ms": 9000 + }, + { + "item_id": "11", + "provider": "library", + "name": "Test Audiobook 0", + "version": "", + "sort_name": "test audiobook 0", + "uri": "library://audiobook/11", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "12", + "provider": "library", + "name": "Test Audiobook 1", + "version": "", + "sort_name": "test audiobook 1", + "uri": "library://audiobook/12", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "13", + "provider": "library", + "name": "Test Audiobook 2", + "version": "", + "sort_name": "test audiobook 2", + "uri": "library://audiobook/13", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "14", + "provider": "library", + "name": "Test Audiobook 3", + "version": "", + "sort_name": "test audiobook 3", + "uri": "library://audiobook/14", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "15", + "provider": "library", + "name": "Test Audiobook 4", + "version": "", + "sort_name": "test audiobook 4", + "uri": "library://audiobook/15", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_podcasts.json b/tests/components/music_assistant/fixtures/library_podcasts.json new file mode 100644 index 00000000000..2c6a9c62f65 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_podcasts.json @@ -0,0 +1,309 @@ +{ + "library_podcasts": [ + { + "item_id": "6", + "provider": "library", + "name": "Test Podcast 0", + "version": "", + "sort_name": "test podcast 0", + "uri": "library://podcast/6", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "7", + "provider": "library", + "name": "Test Podcast 1", + "version": "", + "sort_name": "test podcast 1", + "uri": "library://podcast/7", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "8", + "provider": "library", + "name": "Test Podcast 2", + "version": "", + "sort_name": "test podcast 2", + "uri": "library://podcast/8", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "9", + "provider": "library", + "name": "Test Podcast 3", + "version": "", + "sort_name": "test podcast 3", + "uri": "library://podcast/9", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "10", + "provider": "library", + "name": "Test Podcast 4", + "version": "", + "sort_name": "test podcast 4", + "uri": "library://podcast/10", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + } + ] +} diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr index 6c30ffc512c..32c8776c953 100644 --- a/tests/components/music_assistant/snapshots/test_actions.ambr +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -1,5 +1,195 @@ # serializer version: 1 -# name: test_get_library_action +# name: test_get_library_action[album] + dict({ + 'items': list([ + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'A Space Love Adventure', + 'uri': 'library://artist/289', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synth Punk EP', + 'uri': 'library://album/396', + 'version': '', + }), + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Various Artists', + 'uri': 'library://artist/96', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synthwave (The 80S Revival)', + 'uri': 'library://album/95', + 'version': 'The 80S Revival', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[artist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'W O L F C L U B', + 'uri': 'library://artist/127', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[audiobook] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook', + 'uri': 'library://audiobook/1', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 0', + 'uri': 'library://audiobook/11', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 1', + 'uri': 'library://audiobook/12', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 2', + 'uri': 'library://audiobook/13', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 3', + 'uri': 'library://audiobook/14', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 4', + 'uri': 'library://audiobook/15', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[playlist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '1970s Rock Hits', + 'uri': 'library://playlist/40', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[podcast] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 0', + 'uri': 'library://podcast/6', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 1', + 'uri': 'library://podcast/7', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 2', + 'uri': 'library://podcast/8', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 3', + 'uri': 'library://podcast/9', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 4', + 'uri': 'library://podcast/10', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[radio] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'fm4 | ORF | HQ', + 'uri': 'library://radio/1', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[track] dict({ 'items': list([ dict({ @@ -192,8 +382,12 @@ ]), 'artists': list([ ]), + 'audiobooks': list([ + ]), 'playlists': list([ ]), + 'podcasts': list([ + ]), 'radio': list([ ]), 'tracks': list([ diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index 4d3917091c1..ba8b1acdeac 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.media_items import SearchResults +import pytest from syrupy import SnapshotAssertion from homeassistant.components.music_assistant.actions import ( @@ -47,9 +48,22 @@ async def test_search_action( assert response == snapshot +@pytest.mark.parametrize( + "media_type", + [ + "artist", + "album", + "track", + "playlist", + "audiobook", + "podcast", + "radio", + ], +) async def test_get_library_action( hass: HomeAssistant, music_assistant_client: MagicMock, + media_type: str, snapshot: SnapshotAssertion, ) -> None: """Test music assistant get_library action.""" @@ -60,7 +74,7 @@ async def test_get_library_action( { ATTR_CONFIG_ENTRY_ID: entry.entry_id, ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: "track", + ATTR_MEDIA_TYPE: media_type, }, blocking=True, return_response=True, From 0891669aee47e042788860dcafa96effb64aa688 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 09:25:38 -0600 Subject: [PATCH 1335/1435] Move climate intent to homeassistant integration (#139371) * Move climate intent to homeassistant integration * Move get temperature intent to intent integration * Clean up old test --- homeassistant/components/climate/__init__.py | 1 - homeassistant/components/climate/const.py | 1 - homeassistant/components/climate/intent.py | 43 +- homeassistant/components/intent/__init__.py | 44 ++ homeassistant/helpers/intent.py | 1 + homeassistant/helpers/llm.py | 11 +- tests/components/climate/test_intent.py | 330 -------------- tests/components/intent/test_temperature.py | 456 +++++++++++++++++++ 8 files changed, 508 insertions(+), 379 deletions(-) create mode 100644 tests/components/intent/test_temperature.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 3ea0f887e76..287a2397121 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -68,7 +68,6 @@ from .const import ( # noqa: F401 FAN_ON, FAN_TOP, HVAC_MODES, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index d347ccbbb29..ecc0066cd93 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" -INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9837a326188..7691a2db0f1 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,4 +1,4 @@ -"""Intents for the client integration.""" +"""Intents for the climate integration.""" from __future__ import annotations @@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_TEMPERATURE, DOMAIN, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, @@ -20,49 +19,9 @@ from . import ( async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" - intent.async_register(hass, GetTemperatureIntent()) intent.async_register(hass, SetTemperatureIntent()) -class GetTemperatureIntent(intent.IntentHandler): - """Handle GetTemperature intents.""" - - intent_type = INTENT_GET_TEMPERATURE - description = "Gets the current temperature of a climate device or entity" - slot_schema = { - vol.Optional("area"): intent.non_empty_string, - vol.Optional("name"): intent.non_empty_string, - } - platforms = {DOMAIN} - - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Handle the intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - - name: str | None = None - if "name" in slots: - name = slots["name"]["value"] - - area: str | None = None - if "area" in slots: - area = slots["area"]["value"] - - match_constraints = intent.MatchTargetsConstraints( - name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant - ) - match_result = intent.async_match_targets(hass, match_constraints) - if not match_result.is_match: - raise intent.MatchFailedError( - result=match_result, constraints=match_constraints - ) - - response = intent_obj.create_response() - response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=match_result.states) - return response - - class SetTemperatureIntent(intent.IntentHandler): """Handle SetTemperature intents.""" diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a1451f8fcca..2f9587e2173 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -9,6 +9,7 @@ from aiohttp import web import voluptuous as vol from homeassistant.components import http +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -140,6 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) intent.async_register(hass, RespondIntentHandler()) + intent.async_register(hass, GetTemperatureIntent()) return True @@ -444,6 +446,48 @@ class RespondIntentHandler(intent.IntentHandler): return response +class GetTemperatureIntent(intent.IntentHandler): + """Handle GetTemperature intents.""" + + intent_type = intent.INTENT_GET_TEMPERATURE + description = "Gets the current temperature of a climate device or entity" + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + } + platforms = {CLIMATE_DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] + + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area, + domains=[CLIMATE_DOMAIN], + assistant=intent_obj.assistant, + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index c93545ed414..cecb84d0373 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" INTENT_BROADCAST = "HassBroadcast" +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2ef785e7f71..4ad2bdd6563 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -19,7 +19,6 @@ from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers @@ -285,7 +284,7 @@ class AssistAPI(API): """API exposing Assist API to LLMs.""" IGNORE_INTENTS = { - INTENT_GET_TEMPERATURE, + intent.INTENT_GET_TEMPERATURE, INTENT_GET_WEATHER, INTENT_OPEN_COVER, # deprecated INTENT_CLOSE_COVER, # deprecated @@ -530,9 +529,11 @@ def _get_exposed_entities( info["areas"] = ", ".join(area_names) if attributes := { - attr_name: str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) for attr_name, attr_value in state.attributes.items() if attr_name in interesting_attributes }: diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 65d607e618b..4ce06199eb8 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -14,7 +14,6 @@ from homeassistant.components.climate import ( HVACMode, intent as climate_intent, ) -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -131,335 +130,6 @@ class MockClimateEntityNoSetTemperature(ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] -async def test_get_temperature( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to different areas: - # climate_1 => living room - # climate_2 => bedroom - # nothing in office - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - office_area = area_registry.async_create(name="Office") - - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - - # First climate entity will be selected (no area) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 - - # Select by area (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Select by name (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Check area with no climate entities - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, - assistant=conversation.DOMAIN, - ) - - # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name is None - assert constraints.area_name == office_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Does not exist"}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME - constraints = error.value.constraints - assert constraints.name == "Does not exist" - assert constraints.area_name is None - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name with area - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name == "Climate 1" - assert constraints.area_name == bedroom_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - -async def test_get_temperature_no_entities( - hass: HomeAssistant, -) -> None: - """Test HassClimateGetTemperature intent with no climate entities.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - await create_mock_platform(hass, []) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN - - -async def test_not_exposed( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent when entities aren't exposed.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to same area - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity( - climate_2.entity_id, area_id=living_room_area.id - ) - - # Should fail with empty name - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Should fail with empty area - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Expose second, hide first - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the area should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the exposed entity should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_2.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the *unexposed* entity should fail - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_1.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Expose first, hide second - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_1.entity_id - - # Wrong area name - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA - - # Neither are exposed - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with area - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with both names - for name in (climate_1.name, climate_2.name): - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - async def test_set_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py new file mode 100644 index 00000000000..0279fa44b28 --- /dev/null +++ b/tests/components/intent/test_temperature.py @@ -0,0 +1,456 @@ +"""Test temperature intents.""" + +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [CLIMATE_DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{CLIMATE_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in office + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # First climate entity will be selected (no area) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert response.matched_states + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Check area with no climate entities + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Does not exist"}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME + constraints = error.value.constraints + assert constraints.name == "Does not exist" + assert constraints.area_name is None + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name with area + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name == "Climate 1" + assert constraints.area_name == bedroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_not_exposed( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) + + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT From d8a259044fd07915b029cee986c53b770f82c309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 19:23:46 +0100 Subject: [PATCH 1336/1435] Bump aiohomeconnect to 0.15.1 (#139445) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 28714b31679..2f5ef4d1b37 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.0"], + "requirements": ["aiohomeconnect==0.15.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 0e47f66c965..f8efdb35022 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 642494927a8..43fa107bb81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 From df4e5a54e36f8a74d3676c3a9fcfa168b18e52c8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 18:39:18 +0100 Subject: [PATCH 1337/1435] Fix SmartThings diagnostics (#139447) --- homeassistant/components/smartthings/diagnostics.py | 9 ++++----- tests/components/smartthings/test_diagnostics.py | 5 +++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index bcf40645d22..fc34415e419 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -21,25 +21,24 @@ async def async_get_device_diagnostics( hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" + client = entry.runtime_data.client device_id = next( identifier for identifier in device.identifiers if identifier[0] == DOMAIN - )[0] + )[1] + + device_status = await client.get_device_status(device_id) events: list[DeviceEvent] = [] def register_event(event: DeviceEvent) -> None: events.append(event) - client = entry.runtime_data.client - listener = client.add_device_event_listener(device_id, register_event) await asyncio.sleep(EVENT_WAIT_TIME) listener() - device_status = await client.get_device_status(device_id) - status: dict[str, Any] = {} for component, capabilities in device_status.items(): status[component] = {} diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index 22f1c77cdd1..768be155c86 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -34,6 +34,8 @@ async def test_device( identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} ) + mock_smartthings.get_device_status.reset_mock() + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): diag = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device @@ -42,3 +44,6 @@ async def test_device( assert diag == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) + mock_smartthings.get_device_status.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d" + ) From 46ec3987a87e49b9ecc6d2dd95d6d70a7aac699f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 20:30:18 +0100 Subject: [PATCH 1338/1435] Bump pysmartthings to 2.0.1 (#139454) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index c5277241aa4..1f52cd23ff3 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.0"] + "requirements": ["pysmartthings==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f8efdb35022..fcd7285a2a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43fa107bb81..c4382080448 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 From 3985f1c6c893e35e3e8c648b8000cc12ddfabc78 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 28 Feb 2025 11:18:16 +0100 Subject: [PATCH 1339/1435] Change webdav namespace to absolut URI (#139456) * Change webdav namespace to absolut URI * Add const file --- homeassistant/components/webdav/backup.py | 13 +++++++------ tests/components/webdav/const.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a51866fde61..f810547022b 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) METADATA_VERSION = "1" BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) +NAMESPACE = "https://home-assistant.io" async def async_get_backup_agents( @@ -100,14 +101,14 @@ def _is_current_metadata_version(properties: list[Property]) -> bool: return any( prop.value == METADATA_VERSION for prop in properties - if prop.namespace == "homeassistant" and prop.name == "metadata_version" + if prop.namespace == NAMESPACE and prop.name == "metadata_version" ) def _backup_id_from_properties(properties: list[Property]) -> str | None: """Return the backup ID from properties.""" for prop in properties: - if prop.namespace == "homeassistant" and prop.name == "backup_id": + if prop.namespace == NAMESPACE and prop.name == "backup_id": return prop.value return None @@ -186,12 +187,12 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", [ Property( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", value=backup.backup_id, ), Property( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", value=METADATA_VERSION, ), @@ -252,11 +253,11 @@ class WebDavBackupAgent(BackupAgent): self._backup_path, [ PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", ), PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", ), ], diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 52cad9a163b..8d6b8ad67d7 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -20,12 +20,12 @@ MOCK_LIST_WITH_PROPERTIES = { "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="backup_id", value="23e64aec", ), Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="metadata_version", value="1", ), From b501999a4c06d6986effdc6cbb4091ab11a61116 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 20:42:04 +0100 Subject: [PATCH 1340/1435] Improve onedrive migration (#139458) --- homeassistant/components/onedrive/__init__.py | 40 ++++++++++++++----- tests/components/onedrive/test_init.py | 27 +++++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 454c782af92..f10b8fe0d91 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -41,14 +41,7 @@ _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: - await session.async_ensure_token_valid() - return cast(str, session.token[CONF_ACCESS_TOKEN]) - - client = OneDriveClient(get_access_token, async_get_clientsession(hass)) + client, get_access_token = await _get_onedrive_client(hass, entry) # get approot, will be created automatically if it does not exist approot = await _handle_item_operation(client.get_approot, "approot") @@ -164,20 +157,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) - _LOGGER.debug( "Migrating OneDrive config entry from version %s.%s", version, minor_version ) - + client, _ = await _get_onedrive_client(hass, entry) instance_id = await async_get_instance_id(hass) + try: + approot = await client.get_approot() + folder = await client.get_drive_item( + f"{approot.id}:/backups_{instance_id[:8]}:" + ) + except OneDriveException: + _LOGGER.exception("Migration to version 1.2 failed") + return False + hass.config_entries.async_update_entry( entry, data={ **entry.data, - CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_ID: folder.id, CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", }, + minor_version=2, ) _LOGGER.debug("Migration to version 1.2 successful") return True +async def _get_onedrive_client( + hass: HomeAssistant, entry: OneDriveConfigEntry +) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]: + """Get OneDrive client.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return ( + OneDriveClient(get_access_token, async_get_clientsession(hass)), + get_access_token, + ) + + async def _handle_item_operation( func: Callable[[], Awaitable[Item]], folder: str ) -> Item: diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index c7765e0a7f8..952ca01e1cb 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -236,7 +236,6 @@ async def test_data_cap_issues( async def test_1_1_to_1_2_migration( hass: HomeAssistant, - mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, mock_folder: Folder, ) -> None: @@ -251,12 +250,34 @@ async def test_1_1_to_1_2_migration( }, ) + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.minor_version == 2 + + +async def test_1_1_to_1_2_migration_failure( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from 1.1 to 1.2 failure.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + # will always 404 after migration, because of dummy id mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") await setup_integration(hass, old_config_entry) - assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id - assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert old_config_entry.minor_version == 1 async def test_migration_guard_against_major_downgrade( From 736ff8828d2fa3e4e67155da4ae34152556a7b5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 10:30:31 +0100 Subject: [PATCH 1341/1435] Bump pysmartthings to 2.1.0 (#139460) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 1f52cd23ff3..5dd570f2751 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.1"] + "requirements": ["pysmartthings==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fcd7285a2a5..9b38c4dd423 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4382080448..5c05f3e2a7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 From d8bf47c1018984e7959437aa9228cb5b8d1e8a56 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 22:50:34 +0100 Subject: [PATCH 1342/1435] Only lowercase SmartThings media input source if we have it (#139468) --- homeassistant/components/smartthings/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index bc986894045..2d817c182da 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -461,7 +461,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="media_input_source", device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, - value_fn=lambda value: value.lower(), + value_fn=lambda value: value.lower() if value else None, ) ] }, From c63aaec09e53beb84c7ffbec2c2888dd44ca54a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 09:15:13 +0100 Subject: [PATCH 1343/1435] Set SmartThings suggested display precision (#139470) --- .../components/smartthings/sensor.py | 5 ++ .../smartthings/snapshots/test_sensor.ambr | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2d817c182da..cd12bf46e25 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -580,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -589,6 +590,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -598,6 +600,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -607,6 +610,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -616,6 +620,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), ] diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 93a683afe82..b67d15bef55 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -545,6 +545,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -597,6 +600,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -649,6 +655,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -753,6 +762,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -807,6 +819,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -959,6 +974,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1011,6 +1029,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1063,6 +1084,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1167,6 +1191,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1221,6 +1248,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1768,6 +1798,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1820,6 +1853,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1872,6 +1908,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1924,6 +1963,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1978,6 +2020,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2326,6 +2371,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2378,6 +2426,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2430,6 +2481,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2614,6 +2668,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2668,6 +2725,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2768,6 +2828,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2820,6 +2883,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2872,6 +2938,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3066,6 +3135,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3120,6 +3192,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3220,6 +3295,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3272,6 +3350,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3324,6 +3405,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3520,6 +3604,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3574,6 +3661,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, From 6de878ffe45698c8345083f83d3d2b8b05a041ac Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 27 Feb 2025 19:10:42 -0800 Subject: [PATCH 1344/1435] Fix Gemini Schema validation for #139416 (#139478) Fixed Schema validation for issue #139477 --- .../conversation.py | 15 ++++++- .../snapshots/test_conversation.ambr | 2 +- .../test_conversation.py | 42 +++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c99c4c07a7d..2c84249dcb3 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -111,9 +111,20 @@ def _format_schema(schema: dict[str, Any]) -> Schema: continue if key == "any_of": val = [_format_schema(subschema) for subschema in val] - if key == "type": + elif key == "type": val = val.upper() - if key == "items": + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": val = _format_schema(val) elif key == "properties": val = {k: _format_schema(v) for k, v in val.items()} 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 7c9bb896bd3..106366fd240 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format='lower', items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 229ee0b323e..5e887d3cab7 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -493,6 +493,42 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, + {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, + ), + ( + {"type": "string", "format": "date-time"}, + {"type": "STRING", "format": "date-time"}, + ), + ( + {"type": "string", "format": "byte"}, + {"type": "STRING"}, + ), + ( + {"type": "number", "format": "float"}, + {"type": "NUMBER", "format": "float"}, + ), + ( + {"type": "number", "format": "double"}, + {"type": "NUMBER", "format": "double"}, + ), + ( + {"type": "number", "format": "hex"}, + {"type": "NUMBER"}, + ), + ( + {"type": "integer", "format": "int32"}, + {"type": "INTEGER", "format": "int32"}, + ), + ( + {"type": "integer", "format": "int64"}, + {"type": "INTEGER", "format": "int64"}, + ), + ( + {"type": "integer", "format": "int8"}, + {"type": "INTEGER"}, + ), ( {"type": "integer", "enum": [1, 2, 3]}, {"type": "STRING", "enum": ["1", "2", "3"]}, @@ -515,11 +551,11 @@ async def test_escape_decode() -> None: ] }, ), - ({"type": "string", "format": "lower"}, {"format": "lower", "type": "STRING"}), - ({"type": "boolean", "format": "bool"}, {"format": "bool", "type": "BOOLEAN"}), + ({"type": "string", "format": "lower"}, {"type": "STRING"}), + ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), ( {"type": "number", "format": "percent"}, - {"type": "NUMBER", "format": "percent"}, + {"type": "NUMBER"}, ), ( { From fdb4c0a81f9310aa7361e5ae4de2829fe2bc172c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:16 +0100 Subject: [PATCH 1345/1435] Fail recorder.backup.async_pre_backup if Home Assistant is not running (#139491) Fail recorder.backup.async_pre_backup if hass is not running --- homeassistant/components/recorder/backup.py | 4 ++- tests/components/recorder/test_backup.py | 38 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/backup.py b/homeassistant/components/recorder/backup.py index d47cbe92bd4..eeebe328007 100644 --- a/homeassistant/components/recorder/backup.py +++ b/homeassistant/components/recorder/backup.py @@ -2,7 +2,7 @@ from logging import getLogger -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from .util import async_migration_in_progress, get_instance @@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.info("Backup start notification, locking database for writes") instance = get_instance(hass) + if hass.state is not CoreState.running: + raise HomeAssistantError("Home Assistant is not running") if async_migration_in_progress(hass): raise HomeAssistantError("Database migration in progress") await instance.lock_database() diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index 08fbef01bdd..bed9e88fcbf 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -1,12 +1,13 @@ """Test backup platform for the Recorder integration.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from unittest.mock import patch import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,6 +20,41 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> assert lock_mock.called +RAISES_HASS_NOT_RUNNING = pytest.raises( + HomeAssistantError, match="Home Assistant is not running" +) + + +@pytest.mark.parametrize( + ("core_state", "expected_result", "lock_calls"), + [ + (CoreState.final_write, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.not_running, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.running, does_not_raise(), 1), + (CoreState.starting, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopped, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0), + ], +) +async def test_async_pre_backup_core_state( + recorder_mock: Recorder, + hass: HomeAssistant, + core_state: CoreState, + expected_result: AbstractContextManager, + lock_calls: int, +) -> None: + """Test pre backup in different core states.""" + hass.set_state(core_state) + with ( # pylint: disable=confusing-with-statement + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, + expected_result, + ): + await async_pre_backup(hass) + assert len(lock_mock.mock_calls) == lock_calls + + async def test_async_pre_backup_with_timeout( recorder_mock: Recorder, hass: HomeAssistant ) -> None: From 342e04974d16d5613cbc9ee1e817d6fa95aa8f38 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 28 Feb 2025 23:26:39 +1000 Subject: [PATCH 1346/1435] Fix shift state in Teslemetry (#139505) * Fix shift state * Different fix --- homeassistant/components/teslemetry/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 70315e92da0..56c8830d736 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from propcache.api import cached_property from teslemetry_stream import Signal +from teslemetry_stream.const import ShiftState from homeassistant.components.sensor import ( RestoreSensor, @@ -69,7 +70,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling_value_fn: Callable[[StateType], StateType] = lambda x: x polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None streaming_key: Signal | None = None - streaming_value_fn: Callable[[StateType], StateType] = lambda x: x + streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x streaming_firmware: str = "2024.26" @@ -212,7 +213,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( polling_available_fn=lambda x: True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), streaming_key=Signal.GEAR, - streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)), + streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, entity_registry_enabled_default=False, From 4300900322bad9c42bff484b41cabc859034ec19 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 14:25:35 +0100 Subject: [PATCH 1347/1435] Improve error handling in CoreBackupReaderWriter (#139508) --- homeassistant/components/backup/manager.py | 27 +++++++++++-- tests/components/backup/test_manager.py | 46 +++++++++++++++++----- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 317de85b823..c8b515e3aee 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -14,6 +14,7 @@ from itertools import chain import json from pathlib import Path, PurePath import shutil +import sys import tarfile import time from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -308,6 +309,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError): _message = "On-the-fly decryption is not supported for this backup." +class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup): + """Raised when multiple exceptions occur.""" + + error_code = "multiple_errors" + + class BackupManager: """Define the format that backup managers can have.""" @@ -1605,10 +1612,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) finally: # Inform integrations the backup is done + # If there's an unhandled exception, we keep it so we can rethrow it in case + # the post backup actions also fail. + unhandled_exc = sys.exception() try: - await manager.async_post_backup_actions() - except BackupManagerError as err: - raise BackupReaderWriterError(str(err)) from err + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err + except Exception as err: + if not unhandled_exc: + raise + # If there's an unhandled exception, we wrap both that and the exception + # from the post backup actions in an ExceptionGroup so the caller is + # aware of both exceptions. + raise BackupManagerExceptionGroup( + f"Multiple errors when creating backup: {unhandled_exc}, {err}", + [unhandled_exc, err], + ) from None def _mkdir_and_generate_backup_contents( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 6e626e63748..e4762f35327 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,6 +8,7 @@ from dataclasses import replace from io import StringIO import json from pathlib import Path +import re import tarfile from typing import Any from unittest.mock import ( @@ -35,6 +36,7 @@ from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( BackupManagerError, + BackupManagerExceptionGroup, BackupManagerState, CreateBackupStage, CreateBackupState, @@ -1646,34 +1648,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: assert str(err.value) == "Error during pre-backup: Test exception" +@pytest.mark.parametrize( + ("unhandled_error", "expected_exception", "expected_msg"), + [ + (None, BackupManagerError, "Error during post-backup: Test exception"), + ( + HomeAssistantError("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ( + Exception("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ], +) @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post(hass: HomeAssistant) -> None: +async def test_exception_platform_post( + hass: HomeAssistant, + unhandled_error: Exception | None, + expected_exception: type[Exception], + expected_msg: str, +) -> None: """Test exception in post step.""" - async def _mock_step(hass: HomeAssistant) -> None: - raise HomeAssistantError("Test exception") - remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=_mock_step, + # We let the pre_backup fail to test that unhandled errors are not discarded + # when post backup fails + async_pre_backup=AsyncMock(side_effect=unhandled_error), + async_post_backup=AsyncMock( + side_effect=HomeAssistantError("Test exception") + ), async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) await setup_backup_integration(hass) - with pytest.raises(BackupManagerError) as err: + with pytest.raises(expected_exception, match=re.escape(expected_msg)): await hass.services.async_call( DOMAIN, "create", blocking=True, ) - assert str(err.value) == "Error during post-backup: Test exception" - @pytest.mark.parametrize( ( From 9e3e6b3f431e45b14db89c03e038678ec674247f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 28 Feb 2025 16:18:57 +0100 Subject: [PATCH 1348/1435] Add diagnostics to onedrive (#139516) * Add diagnostics to onedrive * redact PII * add raw data --- .../components/onedrive/diagnostics.py | 33 +++++++++++++++++++ .../components/onedrive/quality_scale.yaml | 5 +-- .../onedrive/snapshots/test_diagnostics.ambr | 31 +++++++++++++++++ tests/components/onedrive/test_diagnostics.py | 26 +++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/onedrive/diagnostics.py create mode 100644 tests/components/onedrive/snapshots/test_diagnostics.ambr create mode 100644 tests/components/onedrive/test_diagnostics.py diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py new file mode 100644 index 00000000000..0e1ed94e155 --- /dev/null +++ b/homeassistant/components/onedrive/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for OneDrive.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import OneDriveConfigEntry + +TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: OneDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data.coordinator + + data = { + "drive": asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index dd9e7f26102..023410d89b2 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -41,10 +41,7 @@ rules: # Gold devices: done - diagnostics: - status: exempt - comment: | - There is no data to diagnose. + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/onedrive/snapshots/test_diagnostics.ambr b/tests/components/onedrive/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..827b9397313 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'auth_implementation': 'onedrive', + 'folder_id': 'my_folder_id', + 'folder_name': 'name', + 'token': '**REDACTED**', + }), + 'drive': dict({ + 'drive_type': 'personal', + 'id': 'mock_drive_id', + 'name': 'My Drive', + 'owner': dict({ + 'application': None, + 'user': dict({ + 'display_name': '**REDACTED**', + 'email': '**REDACTED**', + 'id': 'id', + }), + }), + 'quota': dict({ + 'deleted': 5, + 'remaining': 805306368, + 'state': 'nearing', + 'total': 5368709120, + 'used': 4250000000, + }), + }), + }) +# --- diff --git a/tests/components/onedrive/test_diagnostics.py b/tests/components/onedrive/test_diagnostics.py new file mode 100644 index 00000000000..f82d9925ee6 --- /dev/null +++ b/tests/components/onedrive/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the diagnostics data provided by the OneDrive integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 94b342f26aea23783dfcba807fb5503142e86372 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 28 Feb 2025 14:14:56 +0100 Subject: [PATCH 1349/1435] Make the Tuya backend library compatible with the newer paho mqtt client. (#139518) * Make the Tuya backend library compatible with the newer paho mqtt client. * Improve classnames and docstrings --- homeassistant/components/tuya/__init__.py | 74 ++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c8a639cd239..32119add5f4 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple +from urllib.parse import urlsplit from tuya_sharing import ( CustomerDevice, @@ -11,6 +12,7 @@ from tuya_sharing import ( SharingDeviceListener, SharingTokenListener, ) +from tuya_sharing.mq import SharingMQ, SharingMQConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple): listener: SharingDeviceListener +if TYPE_CHECKING: + import paho.mqtt.client as mqtt + + +class ManagerCompat(Manager): + """Extended Manager class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides extend refresh_mq method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def refresh_mq(self): + """Refresh the MQTT connection.""" + if self.mq is not None: + self.mq.stop() + self.mq = None + + home_ids = [home.id for home in self.user_homes] + device = [ + device + for device in self.device_map.values() + if hasattr(device, "id") and getattr(device, "set_up", False) + ] + + sharing_mq = SharingMQCompat(self.customer_api, home_ids, device) + sharing_mq.start() + sharing_mq.add_message_listener(self.on_message) + self.mq = sharing_mq + + +class SharingMQCompat(SharingMQ): + """Extended SharingMQ class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides _start method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def _start(self, mq_config: SharingMQConfig) -> mqtt.Client: + """Start the MQTT client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + mqttc = mqtt.Client(client_id=mq_config.client_id) + mqttc.username_pw_set(mq_config.username, mq_config.password) + mqttc.user_data_set({"mqConfig": mq_config}) + mqttc.on_connect = self._on_connect + mqttc.on_message = self._on_message + mqttc.on_subscribe = self._on_subscribe + mqttc.on_log = self._on_log + mqttc.on_disconnect = self._on_disconnect + + url = urlsplit(mq_config.url) + if url.scheme == "ssl": + mqttc.tls_set() + + mqttc.connect(url.hostname, url.port) + + mqttc.loop_start() + return mqttc + + async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" if CONF_APP_TYPE in entry.data: raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") token_listener = TokenListener(hass, entry) - manager = Manager( + manager = ManagerCompat( TUYA_CLIENT_ID, entry.data[CONF_USER_CODE], entry.data[CONF_TERMINAL_ID], From d2e19c829d402303174715782006e633b0b1ce6e Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 28 Feb 2025 16:00:31 +0100 Subject: [PATCH 1350/1435] Suppress unsupported event 'EVT_USP_RpsPowerDeniedByPsuOverload' by bumping aiounifi to v83 (#139519) Bump aiounifi to v83 --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f5ad99b72f7..dd255c57c13 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==82"], + "requirements": ["aiounifi==83"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 9b38c4dd423..79fa1a40d37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -404,7 +404,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c05f3e2a7d..40545a50d4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,7 +386,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 From a786ff53ff7f087f32cd7fa5c2e7985a68c23721 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 14:30:47 +0100 Subject: [PATCH 1351/1435] Don't split wheels builder anymore (#139522) --- .github/workflows/wheels.yml | 40 ++---------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7c02c8d97cd..4b1628c57bb 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -218,15 +218,7 @@ jobs: sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt - - name: Split requirements all - run: | - # We split requirements all into multiple files. - # This is to prevent the build from running out of memory when - # resolving packages on 32-bits systems (like armhf, armv7). - - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - - - name: Build wheels (part 1) + - name: Build wheels uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} @@ -238,32 +230,4 @@ jobs: skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtaa" - - - name: Build wheels (part 2) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtab" - - - name: Build wheels (part 3) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtac" + requirements: "requirements_all.txt" From 07128ba06372284df7b69ad0efb591e37f5c6d98 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 15:47:51 +0100 Subject: [PATCH 1352/1435] Bump yt-dlp to 2025.02.19 (#139526) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index f0f8ee03ad0..575c0fa878d 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.01.26"], + "requirements": ["yt-dlp[default]==2025.02.19"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 79fa1a40d37..19a5e9ff261 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40545a50d4c..981c3c129c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2526,7 +2526,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zamg zamg==0.3.6 From 09c129de4001e4b373ccb337ce9dcbf83cf497d1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2025 17:05:36 +0100 Subject: [PATCH 1353/1435] Update frontend to 20250228.0 (#139531) --- 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 5399b22f075..d8eb53467f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250227.0"] + "requirements": ["home-assistant-frontend==20250228.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b8e0b417353..54401a12592 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 19a5e9ff261..7e1f7b23240 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 981c3c129c1..ce309b4460e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 178d509d56749dec2c92b8f2532e834ffd28746a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2025 17:06:59 +0100 Subject: [PATCH 1354/1435] Bump version to 2025.3.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 f22037b9e1d..e295e6b3b91 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 = 3 -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 464b236353f..439cb650a6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b1" +version = "2025.3.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 17c16144d15cd77b206668a023a7c3a8cae3553d Mon Sep 17 00:00:00 2001 From: LaithBudairi <69572447+LaithBudairi@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:58:39 +0200 Subject: [PATCH 1355/1435] Add missing 'state_class' attribute for Growatt plant sensors (#132145) * Add missing 'state_class' attribute for Growatt plant sensors * Update total.py * Update total.py 'TOTAL_INCREASING' * Update total.py "maximum_output" -> 'TOTAL_INCREASING' * Update homeassistant/components/growatt_server/sensor/total.py --------- Co-authored-by: Franck Nijhof --- homeassistant/components/growatt_server/sensor/total.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py index 8111728d1e9..578745c8610 100644 --- a/homeassistant/components/growatt_server/sensor/total.py +++ b/homeassistant/components/growatt_server/sensor/total.py @@ -26,6 +26,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="todayEnergy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="total_output_power", @@ -33,6 +34,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="invTodayPpv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="total_energy_output", From 17116fcd6cfbb10c4455b07c97764e38ad6670bd Mon Sep 17 00:00:00 2001 From: M-A Date: Sat, 1 Mar 2025 13:58:45 -0500 Subject: [PATCH 1356/1435] Bump env_canada to 0.8.0 (#138237) * Bump env_canada to 0.8.0 * Fix requirements*.txt * Grepped more --------- Co-authored-by: Franck Nijhof --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/script/test_gen_requirements_all.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 76534662ff7..fc05e093b33 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.7.2"] + "requirements": ["env-canada==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e1f7b23240..3a4f70ec97e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -869,7 +869,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce309b4460e..458119c43bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 519a5c21855..b667bdd3ddf 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -41,9 +41,9 @@ def test_requirement_override_markers() -> None: ): assert ( gen_requirements_all.process_action_requirement( - "env-canada==0.7.2", "pytest" + "env-canada==0.8.0", "pytest" ) - == "env-canada==0.7.2;python_version<'3.13'" + == "env-canada==0.8.0;python_version<'3.13'" ) assert ( gen_requirements_all.process_action_requirement("other==1.0", "pytest") From 2636a4733390d085be09f09ec278e196b6145903 Mon Sep 17 00:00:00 2001 From: Martreides <8385298+Martreides@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:32:10 +0100 Subject: [PATCH 1357/1435] Fix Nederlandse Spoorwegen to ignore trains in the past (#138331) * Update NS integration to show first next train instead of just the first. * Handle no first or next trip. * Remove debug statement. * Remove seconds and revert back to minutes. * Make use of dt_util.now(). * Fix issue with next train if no first train. --- .../nederlandse_spoorwegen/sensor.py | 120 +++++++++++------- 1 file changed, 77 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ff3eea9252c..1e7fc54f4f7 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity): self._time = time self._state = None self._trips = None + self._first_trip = None + self._next_trip = None @property def name(self): @@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if not self._trips: + if not self._trips or self._first_trip is None: return None - if self._trips[0].trip_parts: - route = [self._trips[0].departure] - route.extend(k.destination for k in self._trips[0].trip_parts) + if self._first_trip.trip_parts: + route = [self._first_trip.departure] + route.extend(k.destination for k in self._first_trip.trip_parts) # Static attributes attributes = { - "going": self._trips[0].going, + "going": self._first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": self._trips[0].departure_platform_actual, + "departure_platform_planned": self._first_trip.departure_platform_planned, + "departure_platform_actual": self._first_trip.departure_platform_actual, "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_planned": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": self._trips[0].arrival_platform_actual, + "arrival_platform_planned": self._first_trip.arrival_platform_planned, + "arrival_platform_actual": self._first_trip.arrival_platform_actual, "next": None, - "status": self._trips[0].status.lower(), - "transfers": self._trips[0].nr_transfers, + "status": self._first_trip.status.lower(), + "transfers": self._first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._trips[0].departure_time_planned is not None: - attributes["departure_time_planned"] = self._trips[ - 0 - ].departure_time_planned.strftime("%H:%M") + if self._first_trip.departure_time_planned is not None: + attributes["departure_time_planned"] = ( + self._first_trip.departure_time_planned.strftime("%H:%M") + ) # Actual departure attributes - if self._trips[0].departure_time_actual is not None: - attributes["departure_time_actual"] = self._trips[ - 0 - ].departure_time_actual.strftime("%H:%M") + if self._first_trip.departure_time_actual is not None: + attributes["departure_time_actual"] = ( + self._first_trip.departure_time_actual.strftime("%H:%M") + ) # Delay departure attributes if ( @@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity): attributes["departure_delay"] = True # Planned arrival attributes - if self._trips[0].arrival_time_planned is not None: - attributes["arrival_time_planned"] = self._trips[ - 0 - ].arrival_time_planned.strftime("%H:%M") + if self._first_trip.arrival_time_planned is not None: + attributes["arrival_time_planned"] = ( + self._first_trip.arrival_time_planned.strftime("%H:%M") + ) # Actual arrival attributes - if self._trips[0].arrival_time_actual is not None: - attributes["arrival_time_actual"] = self._trips[ - 0 - ].arrival_time_actual.strftime("%H:%M") + if self._first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = ( + self._first_trip.arrival_time_actual.strftime("%H:%M") + ) # Delay arrival attributes if ( @@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity): attributes["arrival_delay"] = True # Next attributes - if len(self._trips) > 1: - if self._trips[1].departure_time_actual is not None: - attributes["next"] = self._trips[1].departure_time_actual.strftime( - "%H:%M" - ) - elif self._trips[1].departure_time_planned is not None: - attributes["next"] = self._trips[1].departure_time_planned.strftime( - "%H:%M" - ) + if self._next_trip.departure_time_actual is not None: + attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") + elif self._next_trip.departure_time_planned is not None: + attributes["next"] = self._next_trip.departure_time_planned.strftime( + "%H:%M" + ) + else: + attributes["next"] = None return attributes @@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity): ): self._state = None self._trips = None + self._first_trip = None return # Set the search parameter to search from a specific trip time @@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity): .strftime("%d-%m-%Y %H:%M") ) else: - trip_time = datetime.now().strftime("%d-%m-%Y %H:%M") + trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") try: self._trips = self._nsapi.get_trips( trip_time, self._departure, self._via, self._heading, True, 0, 2 ) if self._trips: - if self._trips[0].departure_time_actual is None: - planned_time = self._trips[0].departure_time_planned - self._state = planned_time.strftime("%H:%M") + all_times = [] + + # If a train is delayed we can observe this through departure_time_actual. + for trip in self._trips: + if trip.departure_time_actual is None: + all_times.append(trip.departure_time_planned) + else: + all_times.append(trip.departure_time_actual) + + # Remove all trains that already left. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > dt_util.now() + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._first_trip = self._trips[sorted_times[0][0]] + self._state = sorted_times[0][1].strftime("%H:%M") + + # Filter again to remove trains that leave at the exact same time. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > sorted_times[0][1] + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._next_trip = self._trips[sorted_times[0][0]] + else: + self._next_trip = None + else: - actual_time = self._trips[0].departure_time_actual - self._state = actual_time.strftime("%H:%M") + self._first_trip = None + self._state = None + except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, From 108b71d33cda7a259f99ba4271b8f6d09bff6b11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:13:06 -0600 Subject: [PATCH 1358/1435] Use multiple indexed group-by queries to get start time states for MySQL (#138786) * tweaks * mysql * mysql * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/const.py * Update homeassistant/components/recorder/statistics.py * Apply suggestions from code review * mysql * mysql * cover * make sure db is fully init on old schema * fixes * fixes * coverage * coverage * coverage * s/slow_dependant_subquery/slow_dependent_subquery/g * reword * comment that callers are responsible for staying under the limit * comment that callers are responsible for staying under the limit * switch to kwargs * reduce branching complexity * split stats query * preen * split tests * split tests --- homeassistant/components/recorder/const.py | 6 + .../components/recorder/history/modern.py | 149 +++- .../components/recorder/models/database.py | 10 + .../components/recorder/statistics.py | 79 ++- homeassistant/components/recorder/util.py | 29 +- .../history/test_websocket_api_schema_32.py | 6 +- tests/components/recorder/common.py | 13 +- tests/components/recorder/conftest.py | 7 + ...est_filters_with_entityfilter_schema_37.py | 15 +- tests/components/recorder/test_history.py | 27 +- .../recorder/test_history_db_schema_32.py | 5 +- .../recorder/test_history_db_schema_42.py | 5 +- .../recorder/test_purge_v32_schema.py | 4 +- tests/components/recorder/test_statistics.py | 653 +++++++++++++++++- tests/components/recorder/test_util.py | 11 +- 15 files changed, 965 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index b7ee984558c..36ff63a0496 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -30,6 +30,12 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 +# As soon as we have more than 999 ids, split the query as the +# MySQL optimizer handles it poorly and will no longer +# do an index only scan with a group-by +# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 +MAX_IDS_FOR_INDEXED_GROUP_BY = 999 + # The maximum number of rows (events) we purge in one delete statement DEFAULT_MAX_BIND_VARS = 4000 diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 8958913bce6..566e30713f0 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -6,11 +6,12 @@ from collections.abc import Callable, Iterable, Iterator from datetime import datetime from itertools import groupby from operator import itemgetter -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import ( CompoundSelect, Select, + StatementLambdaElement, Subquery, and_, func, @@ -26,8 +27,9 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_ from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all -from ..const import LAST_REPORTED_SCHEMA_VERSION +from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY from ..db_schema import ( SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, @@ -149,6 +151,7 @@ def _significant_states_stmt( no_attributes: bool, include_start_time_state: bool, run_start_ts: float | None, + slow_dependent_subquery: bool, ) -> Select | CompoundSelect: """Query the database for significant state changes.""" include_last_changed = not significant_changes_only @@ -187,6 +190,7 @@ def _significant_states_stmt( metadata_ids, no_attributes, include_last_changed, + slow_dependent_subquery, ).subquery(), no_attributes, include_last_changed, @@ -257,7 +261,68 @@ def get_significant_states_with_session( start_time_ts = start_time.timestamp() end_time_ts = datetime_to_timestamp_or_none(end_time) single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None - stmt = lambda_stmt( + rows: list[Row] = [] + if TYPE_CHECKING: + assert instance.database_engine is not None + slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery + if include_start_time_state and slow_dependent_subquery: + # https://github.com/home-assistant/core/issues/137178 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY) + else: + iter_metadata_ids = (metadata_ids,) + for metadata_ids_chunk in iter_metadata_ids: + stmt = _generate_significant_states_with_session_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids_chunk, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + oldest_ts, + slow_dependent_subquery, + ) + row_chunk = cast( + list[Row], + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + ) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return _sorted_states_to_dict( + rows, + start_time_ts if include_start_time_state else None, + entity_ids, + entity_id_to_metadata_id, + minimal_response, + compressed_state_format, + no_attributes=no_attributes, + ) + + +def _generate_significant_states_with_session_stmt( + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], + metadata_ids_in_significant_domains: list[int], + significant_changes_only: bool, + no_attributes: bool, + include_start_time_state: bool, + oldest_ts: float | None, + slow_dependent_subquery: bool, +) -> StatementLambdaElement: + return lambda_stmt( lambda: _significant_states_stmt( start_time_ts, end_time_ts, @@ -268,6 +333,7 @@ def get_significant_states_with_session( no_attributes, include_start_time_state, oldest_ts, + slow_dependent_subquery, ), track_on=[ bool(single_metadata_id), @@ -276,17 +342,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, + slow_dependent_subquery, ], ) - return _sorted_states_to_dict( - execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), - start_time_ts if include_start_time_state else None, - entity_ids, - entity_id_to_metadata_id, - minimal_response, - compressed_state_format, - no_attributes=no_attributes, - ) def get_full_significant_states_with_session( @@ -554,13 +612,14 @@ def get_last_state_changes( ) -def _get_start_time_state_for_entities_stmt( +def _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time: float, metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, ) -> Select: """Baked query to get states for specific entities.""" + # Engine has a fast dependent subquery optimizer # This query is the result of significant research in # https://github.com/home-assistant/core/issues/132865 # A reverse index scan with a limit 1 is the fastest way to get the @@ -570,7 +629,9 @@ def _get_start_time_state_for_entities_stmt( # before a specific point in time for all entities. stmt = ( _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed, False + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, ) .select_from(StatesMeta) .join( @@ -600,6 +661,55 @@ def _get_start_time_state_for_entities_stmt( ) +def _get_start_time_state_for_entities_stmt_group_by( + epoch_time: float, + metadata_ids: list[int], + no_attributes: bool, + include_last_changed: bool, +) -> Select: + """Baked query to get states for specific entities.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + most_recent_states_for_entities_by_date = ( + select( + States.metadata_id.label("max_metadata_id"), + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() + ) + stmt = ( + _stmt_and_join_attributes_for_start_state( + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, + ) + .join( + most_recent_states_for_entities_by_date, + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + ) + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + + def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: @@ -620,6 +730,7 @@ def _get_start_time_state_stmt( metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, + slow_dependent_subquery: bool, ) -> Select: """Return the states at a specific point in time.""" if single_metadata_id: @@ -634,7 +745,15 @@ def _get_start_time_state_stmt( ) # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - return _get_start_time_state_for_entities_stmt( + if slow_dependent_subquery: + return _get_start_time_state_for_entities_stmt_group_by( + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) + + return _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time, metadata_ids, no_attributes, diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index b86fd299793..2a4924edab3 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -37,3 +37,13 @@ class DatabaseOptimizer: # https://wiki.postgresql.org/wiki/Loose_indexscan # https://github.com/home-assistant/core/issues/126084 slow_range_in_select: bool + + # MySQL 8.x+ can end up with a file-sort on a dependent subquery + # which makes the query painfully slow. + # https://github.com/home-assistant/core/issues/137178 + # The solution is to use multiple indexed group-by queries instead + # of the subquery as long as the group by does not exceed + # 999 elements since as soon as we hit 1000 elements MySQL + # will no longer use the group_index_range optimization. + # https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 + slow_dependent_subquery: bool diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c42a0f77c39..97fe73c54fe 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,6 +28,7 @@ from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -59,6 +60,7 @@ from .const import ( INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + MAX_IDS_FOR_INDEXED_GROUP_BY, SupportedDialect, ) from .db_schema import ( @@ -1669,6 +1671,7 @@ def _augment_result_with_change( drop_sum = "sum" not in _types prev_sums = {} if tmp := _statistics_at_time( + get_instance(hass), session, {metadata[statistic_id][0] for statistic_id in result}, table, @@ -2027,7 +2030,39 @@ def get_latest_short_term_statistics_with_session( ) -def _generate_statistics_at_time_stmt( +def _generate_statistics_at_time_stmt_group_by( + table: type[StatisticsBase], + metadata_ids: set[int], + start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + """Create the statement for finding the statistics for a given time.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + return _generate_select_columns_for_types_stmt(table, types) + ( + lambda q: q.join( + most_recent_statistic_ids := ( + select( + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), + ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), + ) + ) + + +def _generate_statistics_at_time_stmt_dependent_sub_query( table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, @@ -2041,8 +2076,7 @@ def _generate_statistics_at_time_stmt( # databases. Since all databases support this query as a join # condition we can use it as a subquery to get the last start_time_ts # before a specific point in time for all entities. - stmt = _generate_select_columns_for_types_stmt(table, types) - stmt += ( + return _generate_select_columns_for_types_stmt(table, types) + ( lambda q: q.select_from(StatisticsMeta) .join( table, @@ -2064,10 +2098,10 @@ def _generate_statistics_at_time_stmt( ) .where(table.metadata_id.in_(metadata_ids)) ) - return stmt def _statistics_at_time( + instance: Recorder, session: Session, metadata_ids: set[int], table: type[StatisticsBase], @@ -2076,8 +2110,41 @@ def _statistics_at_time( ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) - return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + if TYPE_CHECKING: + assert instance.database_engine is not None + if not instance.database_engine.optimizer.slow_dependent_subquery: + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + table=table, + metadata_ids=metadata_ids, + start_time_ts=start_time_ts, + types=types, + ) + return cast(list[Row], execute_stmt_lambda_element(session, stmt)) + rows: list[Row] = [] + # https://github.com/home-assistant/core/issues/132865 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + for metadata_ids_chunk in chunked_or_all( + metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY + ): + stmt = _generate_statistics_at_time_stmt_group_by( + table=table, + metadata_ids=metadata_ids_chunk, + start_time_ts=start_time_ts, + types=types, + ) + row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt)) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return rows def _build_sum_converted_stats( diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a686c7c6498..0acaf0aa68f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -464,6 +464,7 @@ def setup_connection_for_dialect( """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None slow_range_in_select = False + slow_dependent_subquery = False if dialect_name == SupportedDialect.SQLITE: if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] @@ -505,9 +506,8 @@ def setup_connection_for_dialect( result = query_on_connection(dbapi_connection, "SELECT VERSION()") version_string = result[0][0] version = _extract_version_from_server_response(version_string) - is_maria_db = "mariadb" in version_string.lower() - if is_maria_db: + if "mariadb" in version_string.lower(): if not version or version < MIN_VERSION_MARIA_DB: _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB @@ -523,19 +523,21 @@ def setup_connection_for_dialect( instance.hass, version, ) - + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif not version or version < MIN_VERSION_MYSQL: _raise_if_version_unsupported( version or version_string, "MySQL", MIN_VERSION_MYSQL ) - - slow_range_in_select = bool( - not version - or version < MARIADB_WITH_FIXED_IN_QUERIES_105 - or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 - or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 - or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 - ) + else: + # MySQL + # https://github.com/home-assistant/core/issues/137178 + slow_dependent_subquery = True # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") @@ -565,7 +567,10 @@ def setup_connection_for_dialect( return DatabaseEngine( dialect=SupportedDialect(dialect_name), version=version, - optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + optimizer=DatabaseOptimizer( + slow_range_in_select=slow_range_in_select, + slow_dependent_subquery=slow_dependent_subquery, + ), max_bind_vars=DEFAULT_MAX_BIND_VARS, ) diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 7b84c47e81b..c9577e20fcf 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,5 +1,7 @@ """The tests the History component websocket_api.""" +from collections.abc import Generator + import pytest from homeassistant.components import recorder @@ -17,9 +19,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 5e1f02baeed..28eb097f576 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -15,7 +15,7 @@ from typing import Any, Literal, cast from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event as sqlalchemy_event from sqlalchemy.orm.session import Session from homeassistant import core as ha @@ -414,7 +414,15 @@ def create_engine_test_for_schema_version_postfix( schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) old_db_schema = sys.modules[schema_module] + instance: Recorder | None = None + if "hass" in kwargs: + hass: HomeAssistant = kwargs.pop("hass") + instance = recorder.get_instance(hass) engine = create_engine(*args, **kwargs) + if instance is not None: + instance = recorder.get_instance(hass) + instance.engine = engine + sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection) old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: session.add( @@ -435,7 +443,7 @@ def get_schema_module_path(schema_version_postfix: str) -> str: @contextmanager -def old_db_schema(schema_version_postfix: str) -> Iterator[None]: +def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) @@ -455,6 +463,7 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, + hass=hass, schema_version_postfix=schema_version_postfix, ), ), diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 9cdf9dbb372..681205126af 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -13,6 +13,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder import db_schema +from homeassistant.components.recorder.const import MAX_IDS_FOR_INDEXED_GROUP_BY from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -190,3 +191,9 @@ def instrument_migration( instrumented_migration.live_migration_done_stall.set() instrumented_migration.non_live_migration_done_stall.set() yield instrumented_migration + + +@pytest.fixture(params=[1, 2, MAX_IDS_FOR_INDEXED_GROUP_BY]) +def ids_for_start_time_chunk_sizes(request: pytest.FixtureRequest) -> int: + """Fixture to test different chunk sizes for start time query.""" + return request.param diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index d3024df4ed6..2e9883aaf53 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,6 +1,6 @@ """The tests for the recorder filter matching the EntityFilter component.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator import json from unittest.mock import patch @@ -32,12 +32,21 @@ from homeassistant.helpers.entityfilter import ( from .common import async_wait_recording_done, old_db_schema +from tests.typing import RecorderInstanceContextManager + + +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Set up recorder.""" + # This test is for schema 37 and below (32 is new enough to test) @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 166451cc971..d6223eb55b3 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import sentinel +from unittest.mock import patch, sentinel from freezegun import freeze_time import pytest @@ -36,6 +37,24 @@ from .common import ( from tests.typing import RecorderInstanceContextManager +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the recorder to use different chunk sizes for start time query. + + In effect this forces get_significant_states_with_session + to call _generate_significant_states_with_session_stmt multiple times. + """ + with patch( + "homeassistant.components.recorder.history.modern.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -429,6 +448,7 @@ async def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. @@ -443,6 +463,7 @@ async def test_get_significant_states(hass: HomeAssistant) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_minimal_response( hass: HomeAssistant, ) -> None: @@ -512,6 +533,7 @@ async def test_get_significant_states_minimal_response( ) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) async def test_get_significant_states_with_initial( time_zone, hass: HomeAssistant @@ -544,6 +566,7 @@ async def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_without_initial( hass: HomeAssistant, ) -> None: @@ -578,6 +601,7 @@ async def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_entity_id( hass: HomeAssistant, ) -> None: @@ -596,6 +620,7 @@ async def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_multiple_entity_ids( hass: HomeAssistant, ) -> None: diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 142d2fc87f6..908a67cd635 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -50,9 +51,9 @@ def disable_states_meta_manager(): @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 1523f373ea8..20d0c162d35 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -42,9 +43,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_42(): +def db_schema_42(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 42.""" - with old_db_schema("42"): + with old_db_schema(hass, "42"): yield diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 45bef68dabd..0212e4b012e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -58,9 +58,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6e192295c58..ed883c5403e 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,5 +1,6 @@ """The tests for sensor recorder platform.""" +from collections.abc import Generator from datetime import timedelta from typing import Any from unittest.mock import ANY, Mock, patch @@ -18,7 +19,8 @@ from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, PlatformCompiledStatistics, _generate_max_mean_min_statistic_in_sub_period_stmt, - _generate_statistics_at_time_stmt, + _generate_statistics_at_time_stmt_dependent_sub_query, + _generate_statistics_at_time_stmt_group_by, _generate_statistics_during_period_stmt, async_add_external_statistics, async_import_statistics, @@ -57,6 +59,24 @@ from tests.common import MockPlatform, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the statistics query to use different chunk sizes for start time query. + + In effect this forces _statistics_at_time + to call _generate_statistics_at_time_stmt_group_by multiple times. + """ + with patch( + "homeassistant.components.recorder.statistics.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -1113,6 +1133,7 @@ async def test_import_statistics_errors( assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_daily_statistics_sum( @@ -1293,6 +1314,215 @@ async def test_daily_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_multiple_daily_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 1", + "source": "test", + "statistic_id": "test:total_energy_import2", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 2", + "source": "test", + "statistic_id": "test:total_energy_import1", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata1, external_statistics) + async_add_external_statistics(hass, external_metadata2, external_statistics) + + await async_wait_recording_done(hass) + stats = statistics_during_period( + hass, + zero, + period="day", + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) + expected_stats_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + expected_stats = { + "test:total_energy_import1": expected_stats_inner, + "test:total_energy_import2": expected_stats_inner, + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + types={"change"}, + ) + expected_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, + ] + assert stats == { + "test:total_energy_import1": expected_inner, + "test:total_energy_import2": expected_inner, + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={ + "not", + "the", + "same", + "test:total_energy_import1", + "test:total_energy_import2", + }, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=[ + "test:total_energy_import1", + "with_other", + "test:total_energy_import2", + ], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_mean( @@ -1428,6 +1658,7 @@ async def test_weekly_statistics_mean( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_sum( @@ -1608,6 +1839,7 @@ async def test_weekly_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") async def test_monthly_statistics_sum( @@ -1914,20 +2146,43 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt() -> None: - """Test cache key for _generate_statistics_at_time_stmt.""" - stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) +def test_cache_key_for_generate_statistics_at_time_stmt_group_by() -> None: + """Test cache key for _generate_statistics_at_time_stmt_group_by.""" + stmt = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) + stmt2 = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - stmt3 = _generate_statistics_at_time_stmt( + stmt3 = _generate_statistics_at_time_stmt_group_by( StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 +def test_cache_key_for_generate_statistics_at_time_stmt_dependent_sub_query() -> None: + """Test cache key for _generate_statistics_at_time_stmt_dependent_sub_query.""" + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + stmt3 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change( @@ -2263,6 +2518,392 @@ async def test_change( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_change_multiple( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + await async_wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + expected_stats = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + expected_stats_change_sum = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + expected_stats_wh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + expected_stats_mwh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_wh["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats_wh["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_mwh[ + "sensor.total_energy_import1" + ][1:4], + "sensor.total_energy_import2": expected_stats_mwh[ + "sensor.total_energy_import2" + ][1:4], + } + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:3 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:3 + ], + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 3:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 3:4 + ], + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change_with_none( diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index c9020762d4b..6c324f4b01a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -417,7 +417,12 @@ def test_supported_mysql(caplog: pytest.LogCaptureFixture, mysql_version) -> Non dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "mysql", dbapi_connection, True) + database_engine = util.setup_connection_for_dialect( + instance_mock, "mysql", dbapi_connection, True + ) + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is True assert "minimum supported version" not in caplog.text @@ -502,6 +507,7 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -583,6 +589,7 @@ def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> N assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -675,6 +682,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -731,6 +739,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) From b4b7142b55c970e7d4f8ab0a01831eb13faf2634 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 28 Feb 2025 20:51:56 +0100 Subject: [PATCH 1359/1435] Specify recorder as after dependency in sql integration (#139037) * Specify recorder as after dependency in sql integration * Remove hassfest exception --------- Co-authored-by: J. Nick Koston --- homeassistant/components/sql/manifest.json | 1 + script/hassfest/dependencies.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c18b1b9f05f..2b00a5b0d65 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,6 +1,7 @@ { "domain": "sql", "name": "SQL", + "after_dependencies": ["recorder"], "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 368c2f762b8..b22027500dd 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -153,8 +153,6 @@ ALLOWED_USED_COMPONENTS = { } IGNORE_VIOLATIONS = { - # Has same requirement, gets defaults. - ("sql", "recorder"), # Sharing a base class ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), From a0668e5a5bb140b2519cb7c25378107c7d19ec6f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sat, 1 Mar 2025 13:29:50 +0100 Subject: [PATCH 1360/1435] Handle IPv6 URLs in devolo Home Network (#139191) * Handle IPv6 URLs in devolo Home Network * Use yarl --- .../components/devolo_home_network/entity.py | 3 +- .../devolo_home_network/conftest.py | 7 ++++ .../snapshots/test_init.ambr | 37 +++++++++++++++++++ .../devolo_home_network/test_init.py | 4 +- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 93ec1b9a3a2..64d8ff131e8 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -8,6 +8,7 @@ from devolo_plc_api.device_api import ( WifiGuestAccessGet, ) from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork +from yarl import URL from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -43,7 +44,7 @@ class DevoloEntity(Entity): self.entry = entry self._attr_device_info = DeviceInfo( - configuration_url=f"http://{self.device.ip}", + configuration_url=URL.build(scheme="http", host=self.device.ip), identifiers={(DOMAIN, str(self.device.serial_number))}, manufacturer="devolo", model=self.device.product, diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index fd03063cd34..2b3fd989754 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -27,6 +27,13 @@ def mock_repeater_device(mock_device: MockDevice): return mock_device +@pytest.fixture +def mock_ipv6_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device using IPv6.""" + mock_device.ip = "2001:db8::1" + return mock_device + + @pytest.fixture def mock_nonwifi_device(mock_device: MockDevice): """Mock connecting to a devolo home network device without wifi.""" diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index bdc597819a7..5753fd82817 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -36,6 +36,43 @@ 'via_device_id': None, }) # --- +# name: test_setup_entry[mock_ipv6_device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://[2001:db8::1]', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'model_id': '2730', + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- # name: test_setup_entry[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 71823eabe82..56d2c21a5b2 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -27,7 +27,9 @@ from .mock import MockDevice from tests.common import MockConfigEntry -@pytest.mark.parametrize("device", ["mock_device", "mock_repeater_device"]) +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) async def test_setup_entry( hass: HomeAssistant, device: str, From 61a3cc37e010b2ee2b4d03fa3059c540a6c7b959 Mon Sep 17 00:00:00 2001 From: Juan Grande Date: Sat, 1 Mar 2025 00:10:35 -0800 Subject: [PATCH 1361/1435] Fix bug in derivative sensor when source sensor's state is constant (#139230) Previously, when the source sensor's state remains constant, the derivative sensor repeats its latest value indefinitely. This patch fixes this bug by consuming the state_reported event and updating the sensor's output even when the source sensor doesn't change its state. --- homeassistant/components/derivative/sensor.py | 66 ++++++++--- tests/components/derivative/test_sensor.py | 111 ++++++++++++++++-- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 90f8a95919d..f6c2b45ef9c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -24,7 +24,14 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTime, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import ( + Event, + EventStateChangedData, + EventStateReportedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_state_report_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) @callback - def calc_derivative(event: Event[EventStateChangedData]) -> None: + def on_state_reported(event: Event[EventStateReportedData]) -> None: + """Handle constant sensor state.""" + if self._attr_native_value == Decimal(0): + # If the derivative is zero, and the source sensor hasn't + # changed state, then we know it will still be zero. + return + new_state = event.data["new_state"] + if new_state is not None: + calc_derivative( + new_state, new_state.state, event.data["old_last_reported"] + ) + + @callback + def on_state_changed(event: Event[EventStateChangedData]) -> None: + """Handle changed sensor state.""" + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if new_state is not None and old_state is not None: + calc_derivative(new_state, old_state.state, old_state.last_reported) + + def calc_derivative( + new_state: State, old_value: str, old_last_reported: datetime + ) -> None: """Handle the sensor state changes.""" - if ( - (old_state := event.data["old_state"]) is None - or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or (new_state := event.data["new_state"]) is None - or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, ): return @@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._state_list = [ (time_start, time_end, state) for time_start, time_end, state in self._state_list - if (new_state.last_updated - time_end).total_seconds() + if (new_state.last_reported - time_end).total_seconds() < self._time_window ] try: elapsed_time = ( - new_state.last_updated - old_state.last_updated + new_state.last_reported - old_last_reported ).total_seconds() - delta_value = Decimal(new_state.state) - Decimal(old_state.state) + delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value / Decimal(elapsed_time) @@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("While calculating derivative: %s", err) except DecimalException as err: _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err + "Invalid state (%s > %s): %s", old_value, new_state.state, err ) except AssertionError as err: _LOGGER.error("Could not calculate derivative: %s", err) @@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # add latest derivative to the window list self._state_list.append( - (old_state.last_updated, new_state.last_updated, new_derivative) + (old_last_reported, new_state.last_reported, new_derivative) ) def calculate_weight( @@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity): else: derivative = Decimal("0.00") for start, end, value in self._state_list: - weight = calculate_weight(start, end, new_state.last_updated) + weight = calculate_weight(start, end, new_state.last_reported) derivative = derivative + (value * Decimal(weight)) self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() self.async_on_remove( async_track_state_change_event( - self.hass, self._sensor_source_id, calc_derivative + self.hass, self._sensor_source_id, on_state_changed + ) + ) + + self.async_on_remove( + async_track_state_report_event( + self.hass, self._sensor_source_id, on_state_reported ) ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index a543de974f1..f8d88066f16 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -39,7 +39,7 @@ async def test_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}, force_update=True) + hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -51,6 +51,49 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" +async def test_no_change(hass: HomeAssistant) -> None: + """Test derivative sensor state updated when source sensor doesn't change.""" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": "sensor.energy", + "unit": "kW", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + base = dt_util.utcnow() + with freeze_time(base) as freezer: + hass.states.async_set(entity_id, 0, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.derivative") + assert state is not None + + # Testing a energy sensor at 1 kWh for 1hour = 0kW + assert round(float(state.state), config["sensor"]["round"]) == 0.0 + + assert state.attributes.get("unit_of_measurement") == "kW" + + assert state.last_changed == base + timedelta(seconds=2 * 3600) + + async def _setup_sensor( hass: HomeAssistant, config: dict[str, Any] ) -> tuple[dict[str, Any], str]: @@ -86,7 +129,7 @@ async def setup_tests( with freeze_time(base) as freezer: for time, value in zip(times, values, strict=False): freezer.move_to(base + timedelta(seconds=time)) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -159,6 +202,53 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) +async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: + """Test that zeroes are properly handled within the time window.""" + # We simulate the following situation: + # The temperature rises 1 °C per minute for 10 minutes long. Then, it + # stays constant for another 10 minutes. There is a data point every + # minute and we use a time window of 10 minutes. + # Therefore, we can expect the derivative to peak at 1 after 10 minutes + # and then fall down to 0 in steps of 10%. + + temperature_values = [] + for temperature in range(10): + temperature_values += [temperature] + temperature_values += [10] * 11 + time_window = 600 + times = list(range(0, 1200 + 60, 60)) + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.MINUTES, + "round": 1, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_derivative = 0 + for time, value in zip(times, temperature_values, strict=True): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative + + async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: """Test derivative sensor state.""" # We simulate the following situation: @@ -188,7 +278,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time < times[-1] - time_window: @@ -232,7 +322,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time and time > times[3]: @@ -270,7 +360,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) @@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id, 1000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, - 1000, + 2000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") assert state is not None - # Testing a power sensor at 1000 Watts for 1hour = 0kW/h - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + # Testing a power sensor increasing by 1000 Watts per hour = 1kW/h + assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}" @@ -345,7 +433,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1000, {}, force_update=True) + hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: entity_id, value, {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, - force_update=True, ) await hass.async_block_till_done() From a4e71e20557d836c6cbc899383ef54c7d62fb864 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 20:56:43 +0100 Subject: [PATCH 1362/1435] Ensure Hue bridge is added first to the device registry (#139438) --- homeassistant/components/hue/v2/device.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 25a027f9ebe..7bb3d28e962 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -94,7 +94,12 @@ async def async_setup_devices(bridge: HueBridge): add_device(hue_resource) # create/update all current devices found in controllers - known_devices = [add_device(hue_device) for hue_device in dev_controller] + # sort the devices to ensure bridges are added first + hue_devices = list(dev_controller) + hue_devices.sort( + key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2 + ) + known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] From 708f22fe6fdd481dadc1d1c4ba8f15c1228ffde2 Mon Sep 17 00:00:00 2001 From: Filip Agh Date: Sat, 1 Mar 2025 11:50:24 +0100 Subject: [PATCH 1363/1435] Fix update data for multiple Gree devices (#139469) fix sync date for multiple devices do not use handler for explicit update devices as internal communication lib do not provide which device is updated use ha update loop copy data object to prevent rewrite data from internal lib allow more time to process response before log warning about long wait for response and make log message more clear --- homeassistant/components/gree/const.py | 1 + homeassistant/components/gree/coordinator.py | 14 ++++++++++---- tests/components/gree/test_climate.py | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index f926eb1c53e..14236f09fa2 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -20,3 +20,4 @@ MAX_ERRORS = 2 TARGET_TEMPERATURE_STEP = 1 UPDATE_INTERVAL = 60 +MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2 diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 0d1aa60deaa..c8b4e6cff54 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from datetime import datetime, timedelta import logging from typing import Any @@ -24,6 +25,7 @@ from .const import ( DISPATCH_DEVICE_DISCOVERED, DOMAIN, MAX_ERRORS, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) @@ -48,7 +50,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) self.device = device - self.device.add_handler(Response.DATA, self.device_state_updated) self.device.add_handler(Response.RESULT, self.device_state_updated) self._error_count: int = 0 @@ -88,7 +89,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # raise update failed if time for more than MAX_ERRORS has passed since last update now = utcnow() elapsed_success = now - self._last_response_time - if self.update_interval and elapsed_success >= self.update_interval: + if self.update_interval and elapsed_success >= timedelta( + seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL + ): if not self._last_error_time or ( (now - self.update_interval) >= self._last_error_time ): @@ -96,16 +99,19 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._error_count += 1 _LOGGER.warning( - "Device %s is unresponsive for %s seconds", + "Device %s took an unusually long time to respond, %s seconds", self.name, elapsed_success, ) + else: + self._error_count = 0 if self.last_update_success and self._error_count >= MAX_ERRORS: raise UpdateFailed( f"Device {self.name} is unresponsive for too long and now unavailable" ) - return self.device.raw_properties + self._last_response_time = utcnow() + return copy.deepcopy(self.device.raw_properties) async def push_state_update(self): """Send state updates to the physical device.""" diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 0cb187f5a60..d7c011a4c25 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -52,6 +52,7 @@ from homeassistant.components.gree.const import ( DISCOVERY_SCAN_INTERVAL, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) from homeassistant.const import ( @@ -346,7 +347,7 @@ async def test_unresponsive_device( await async_setup_gree(hass) async def run_update(): - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(timedelta(seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() From 8a62b882bf72d14a1aca980ac253201b5028c236 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:39:49 +0100 Subject: [PATCH 1364/1435] Use last event as color mode in SmartThings (#139473) * Use last event as color mode in SmartThings * Use last event as color mode in SmartThings * Fix --- homeassistant/components/smartthings/light.py | 37 +++--- tests/components/smartthings/test_light.py | 116 +++++++++++++++++- 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 54e8ad18a7c..aa3a8d35859 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, SmartThings +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -19,6 +20,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import FullDevice, SmartThingsConfigEntry from .const import MAIN @@ -53,7 +55,7 @@ def convert_scale( return round(value * target_scale / value_scale, round_digits) -class SmartThingsLight(SmartThingsEntity, LightEntity): +class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Define a SmartThings Light.""" _attr_name = None @@ -84,18 +86,28 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): color_modes = set() if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) + self._attr_color_mode = ColorMode.COLOR_TEMP if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) + self._attr_color_mode = ColorMode.HS if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) + if len(color_modes) == 1: + self._attr_color_mode = list(color_modes)[0] self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION self._attr_supported_features = features + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_extra_data()) is not None: + self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE] + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" tasks = [] @@ -195,17 +207,14 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): argument=[level, duration], ) - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if len(self._attr_supported_color_modes) == 1: - # The light supports only a single color mode - return list(self._attr_supported_color_modes)[0] - - # The light supports hs + color temp, determine which one it is - if self._attr_hs_color and self._attr_hs_color[1]: - return ColorMode.HS - return ColorMode.COLOR_TEMP + def _update_handler(self, event: DeviceEvent) -> None: + """Handle device updates.""" + if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE): + self._attr_color_mode = { + Capability.COLOR_CONTROL: ColorMode.HS, + Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP, + }[cast(Capability, event.capability)] + super()._update_handler(event) @property def is_on(self) -> bool: diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 8d47e90c9f5..56eadde748b 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -12,7 +12,12 @@ from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, ColorMode, ) @@ -25,7 +30,7 @@ from homeassistant.const import ( STATE_ON, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import ( @@ -35,7 +40,7 @@ from . import ( trigger_update, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data async def test_all_entities( @@ -228,6 +233,15 @@ async def test_updating_brightness( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 await trigger_update( @@ -252,8 +266,17 @@ async def test_updating_hs( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( - 218.906, + 144.0, 60, ) @@ -280,9 +303,17 @@ async def test_updating_color_temp( ) -> None: """Test color temperature update.""" set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") - set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 3000, + ) + assert ( hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] is ColorMode.COLOR_TEMP @@ -305,3 +336,80 @@ async def test_updating_color_temp( hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] == 2000 ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_modes( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode changes.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 50) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_mode_after_startup( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode after startup.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + RESTORE_DATA = { + ATTR_BRIGHTNESS: 178, + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (144.0, 60), + ATTR_MAX_COLOR_TEMP_KELVIN: 9000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2000, + ATTR_RGB_COLOR: (255, 128, 0), + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP, ColorMode.HS], + ATTR_XY_COLOR: (0.61, 0.35), + } + + mock_restore_cache_with_extra_data( + hass, ((State("light.standing_light", STATE_ON), RESTORE_DATA),) + ) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) From 22af8af132be3bed372584ea9ae7b06c3c228229 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:03:24 +0100 Subject: [PATCH 1365/1435] Set SmartThings delta energy to Total (#139474) --- .../components/smartthings/sensor.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index cd12bf46e25..0a695876da4 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -596,7 +596,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key="deltaEnergy_meter", translation_key="energy_difference", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b67d15bef55..78aa4db62f8 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -582,7 +582,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -620,7 +620,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AC Office Granit Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1011,7 +1011,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1049,7 +1049,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Aire Dormitorio Principal Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1835,7 +1835,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1873,7 +1873,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2408,7 +2408,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2446,7 +2446,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dishwasher Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2865,7 +2865,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2903,7 +2903,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dryer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3332,7 +3332,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -3370,7 +3370,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From dce8bca103b843c6e10907df30c0790b7c716c94 Mon Sep 17 00:00:00 2001 From: StaleLoafOfBread <45444205+StaleLoafOfBread@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:59:35 -0500 Subject: [PATCH 1366/1435] Fix alert not respecting can_acknowledge setting (#139483) * fix(alert): check can_ack prior to acking * fix(alert): add test for when can_acknowledge=False * fix(alert): warn on can_ack blocking an ack * Raise error when trying to acknowledge alert with can_acknowledge set to False * Rewrite can_ack check as guard Co-authored-by: Franck Nijhof * Make can_ack service error msg human readable because it will show up in the UI * format with ruff * Make pytest aware of service error when acking an unackable alert --------- Co-authored-by: Franck Nijhof --- homeassistant/components/alert/entity.py | 5 ++-- tests/components/alert/test_init.py | 30 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py index 629047b15ba..a11b281428f 100644 --- a/homeassistant/components/alert/entity.py +++ b/homeassistant/components/alert/entity.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import ServiceNotFound, ServiceValidationError from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( async_track_point_in_time, @@ -195,7 +195,8 @@ class AlertEntity(Entity): async def async_turn_off(self, **kwargs: Any) -> None: """Async Acknowledge alert.""" - LOGGER.debug("Acknowledged Alert: %s", self._attr_name) + if not self._can_ack: + raise ServiceValidationError("This alert cannot be acknowledged") self._ack = True self.async_write_ha_state() diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 27997a093e5..4407775a582 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -28,6 +28,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import MockEntityPlatform, async_mock_service @@ -116,6 +117,35 @@ async def test_silence(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> assert hass.states.get(ENTITY_ID).state == STATE_ON +async def test_silence_can_acknowledge_false(hass: HomeAssistant) -> None: + """Test that attempting to silence an alert with can_acknowledge=False will not silence.""" + # Create copy of config where can_acknowledge is False + config = deepcopy(TEST_CONFIG) + config[DOMAIN][NAME]["can_acknowledge"] = False + + # Setup the alert component + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Ensure the alert is currently on + hass.states.async_set(ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Attempt to acknowledge + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # The state should still be ON because can_acknowledge=False + assert hass.states.get(ENTITY_ID).state == STATE_ON + + async def test_reset(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test resetting the alert.""" assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) From 6f0c62dc9d6e8a51cc4f656ec8bb275403e79eb5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 21:06:45 +0100 Subject: [PATCH 1367/1435] Bump pysmartthings to 2.2.0 (#139539) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 5dd570f2751..0ca6c1f3b26 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.1.0"] + "requirements": ["pysmartthings==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a4f70ec97e..550f1d6e650 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 458119c43bb..804780d6717 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 From f54b3f4de2f1ea23d21f31c75fbe35c4873220c4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:33:25 +0100 Subject: [PATCH 1368/1435] Remove orphan devices on startup in SmartThings (#139541) --- .../components/smartthings/__init__.py | 17 ++++++++++++++- tests/components/smartthings/test_init.py | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 4bc9b270360..d6de1d3d252 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -21,13 +21,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) @@ -123,6 +124,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + device_id = next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ) + if device_id in entry.runtime_data.devices: + continue + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + return True diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index be88f11903e..372f23eec42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.smartthings.const import DOMAIN @@ -29,3 +30,23 @@ async def test_devices( assert device is not None assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_removing_stale_devices( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing stale devices.""" + mock_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "aaa-bbb-ccc")}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) From 5ad156767a4ebc330fa35f3de3ae70d10120d7df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 21:42:33 +0000 Subject: [PATCH 1369/1435] Bump PySwitchBot to 0.56.1 (#139544) changelog: https://github.com/sblibs/pySwitchbot/compare/0.56.0...0.56.1 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 92a1c25d6f5..567a33a8f43 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.56.0"] + "requirements": ["PySwitchbot==0.56.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 550f1d6e650..826e3252b87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 804780d6717..828d1a44244 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From 8cc587d3a72d6c497c73b5d68881862e1de1e71f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:02:06 +0100 Subject: [PATCH 1370/1435] Bump pysmartthings to 2.3.0 (#139546) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 0ca6c1f3b26..9fa6d28fa0a 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.2.0"] + "requirements": ["pysmartthings==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 826e3252b87..de6aa612528 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 828d1a44244..fbb338f7fb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 From c7d89398a0ae27a84bc8d9ad200dc0ded673c492 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:30:57 +0100 Subject: [PATCH 1371/1435] Improve SmartThings OCF device info (#139547) --- homeassistant/components/smartthings/entity.py | 18 ++++++------------ .../smartthings/snapshots/test_init.ambr | 16 ++++++++-------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 1383196ce15..0d6ee32b473 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from pysmartthings import ( Attribute, @@ -44,19 +44,13 @@ class SmartThingsEntity(Entity): identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, ) - if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: + if (ocf := device.device.ocf) is not None: self._attr_device_info.update( { - "manufacturer": cast( - str | None, ocf[Attribute.MANUFACTURER_NAME].value - ), - "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), - "hw_version": cast( - str | None, ocf[Attribute.HARDWARE_VERSION].value - ), - "sw_version": cast( - str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value - ), + "manufacturer": ocf.manufacturer_name, + "model": ocf.model_number.split("|")[0], + "hw_version": ocf.hardware_version, + "sw_version": ocf.firmware_version, } ) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 546d99a967f..0b5aeb57c18 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -219,7 +219,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model': 'ARTIK051_KRAC_18K', 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, @@ -252,7 +252,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model': 'ARA-WW-TP1-22-COMMON', 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, @@ -285,7 +285,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X', 'model_id': None, 'name': 'Microwave', 'name_by_user': None, @@ -318,7 +318,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model': 'TP2X_REF_20K', 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, @@ -351,7 +351,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model': 'powerbot_7000_17M', 'model_id': None, 'name': 'Robot vacuum', 'name_by_user': None, @@ -384,7 +384,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model': 'DA_DW_A51_20_COMMON', 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, @@ -417,7 +417,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model': 'DA_WM_A51_20_COMMON', 'model_id': None, 'name': 'Dryer', 'name_by_user': None, @@ -450,7 +450,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model': 'DA_WM_TP2_20_COMMON', 'model_id': None, 'name': 'Washer', 'name_by_user': None, From 0323a9c4e6f5ee0bb5cb8b33432d2f5eb739ce9e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:03:57 +0100 Subject: [PATCH 1372/1435] Add SmartThings Viper device info (#139548) --- .../components/smartthings/entity.py | 9 ++++ .../smartthings/snapshots/test_init.ambr | 50 +++++++++---------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0d6ee32b473..f86f3a68f0e 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -53,6 +53,15 @@ class SmartThingsEntity(Entity): "sw_version": ocf.firmware_version, } ) + if (viper := device.device.viper) is not None: + self._attr_device_info.update( + { + "manufacturer": viper.manufacturer_name, + "model": viper.model_name, + "hw_version": viper.hardware_version, + "sw_version": viper.software_version, + } + ) async def async_added_to_hass(self) -> None: """Subscribe to updates.""" diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0b5aeb57c18..e0d93553121 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -86,8 +86,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Arlo', + 'model': 'VMC4041PB', 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, @@ -108,7 +108,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'WoCurtain3-WoCurtain3', 'id': , 'identifiers': set({ tuple( @@ -119,8 +119,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'WonderLabs Company', + 'model': 'WoCurtain3', 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, @@ -471,7 +471,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206213001', 'id': , 'identifiers': set({ tuple( @@ -482,15 +482,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-ecobee3_remote_sensor', 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206213001', 'via_device_id': None, }) # --- @@ -504,7 +504,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206151734', 'id': , 'identifiers': set({ tuple( @@ -515,15 +515,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-thermostat', 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206151734', 'via_device_id': None, }) # --- @@ -603,7 +603,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LTG002', 'id': , 'identifiers': set({ tuple( @@ -614,15 +614,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue ambiance spot', 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -636,7 +636,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LCA001', 'id': , 'identifiers': set({ tuple( @@ -647,15 +647,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue color lamp', 'model_id': None, 'name': 'Standing light', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -735,7 +735,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'SKY40147', 'id': , 'identifiers': set({ tuple( @@ -746,15 +746,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Sensibo', + 'model': 'skyplus', 'model_id': None, 'name': 'Office', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': 'SKY40147', 'via_device_id': None, }) # --- From e1ce5b8c69543edfddc0d3bde814e15ac227753c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:25:50 +0000 Subject: [PATCH 1373/1435] Revert polling changes to HomeKit Controller (#139550) This reverts #116200 We changed the polling logic to avoid polling if all chars are marked as watchable to avoid crashing the firmware on a very limited set of devices as it was more in line with what iOS does. In the end, the user ended up replacing the device in #116143 because it turned out to be unreliable in other ways. The vendor has since issued a firmware update that may resolve the problem with all of these devices. In practice it turns out many more devices report that chars are evented and never send events. After a few months of data and reports the trade-off does not seem worth it since users are having to set up manual polling on a wide range of devices. The amount of devices with evented chars that do not actually send state vastly exceeds the number of devices that might crash if they are polled too often so restore the previous behavior fixes #138561 fixes #100331 fixes #124529 fixes #123456 fixes #130763 fixes #124099 fixes #124916 fixes #135434 fixes #125273 fixes #124099 fixes #119617 --- .../homekit_controller/connection.py | 38 ------------------- .../homekit_controller/test_connection.py | 10 ++--- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 211aec2c2d5..43cbdec67fa 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -154,7 +154,6 @@ class HKDevice: self._pending_subscribes: set[tuple[int, int]] = set() self._subscribe_timer: CALLBACK_TYPE | None = None self._load_platforms_lock = asyncio.Lock() - self._full_update_requested: bool = False @property def entity_map(self) -> Accessories: @@ -841,48 +840,11 @@ class HKDevice: async def async_request_update(self, now: datetime | None = None) -> None: """Request an debounced update from the accessory.""" - self._full_update_requested = True await self._debounced_update.async_call() async def async_update(self, now: datetime | None = None) -> None: """Poll state of all entities attached to this bridge/accessory.""" to_poll = self.pollable_characteristics - accessories = self.entity_map.accessories - - if ( - not self._full_update_requested - and len(accessories) == 1 - and self.available - and not (to_poll - self.watchable_characteristics) - and self.pairing.is_available - and await self.pairing.controller.async_reachable( - self.unique_id, timeout=5.0 - ) - ): - # If its a single accessory and all chars are watchable, - # only poll the firmware version to keep the connection alive - # https://github.com/home-assistant/core/issues/123412 - # - # Firmware revision is used here since iOS does this to keep camera - # connections alive, and the goal is to not regress - # https://github.com/home-assistant/core/issues/116143 - # by polling characteristics that are not normally polled frequently - # and may not be tested by the device vendor. - # - _LOGGER.debug( - "Accessory is reachable, limiting poll to firmware version: %s", - self.unique_id, - ) - first_accessory = accessories[0] - accessory_info = first_accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) - assert accessory_info is not None - firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid - to_poll = {(first_accessory.aid, firmware_iid)} - - self._full_update_requested = False - if not to_poll: self.async_update_available_state() _LOGGER.debug( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 7ea791f9a1e..00c7bb16259 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -375,9 +375,9 @@ async def test_poll_firmware_version_only_all_watchable_accessory_mode( state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 7)} + # Verify everything is polled + assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} + assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} # Test device goes offline helper.pairing.available = False @@ -429,8 +429,8 @@ async def test_manual_poll_all_chars( ) as mock_get_characteristics: # Initial state is that the light is off await helper.poll_and_get_state() - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} + # Verify poll polls all chars + assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 # Now do a manual poll to ensure all chars are polled mock_get_characteristics.reset_mock() From 21277a81d3a973426be3af68ca89c146f563f8f7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:06:16 +0100 Subject: [PATCH 1374/1435] Bump pysmartthings to 2.4.0 (#139564) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 9fa6d28fa0a..e0cf6739290 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.3.0"] + "requirements": ["pysmartthings==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index de6aa612528..28395fa3e79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbb338f7fb7..c33cf54af48 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 From f56d65b2ec3428c522eca53b7c8adaf0af649e7d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 2 Mar 2025 08:58:15 +1000 Subject: [PATCH 1375/1435] Bump Tesla Fleet API to v0.9.12 (#139565) * bump * Update manifest.json * Fix versions * remove tesla_bluetooth * Remove mistake --- 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 bb8f6041771..53aff3d0a54 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.10"] + "requirements": ["tesla-fleet-api==0.9.12"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index dfe6d7cb3f9..4e9228acd2f 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.10", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d777cf5051e..d4ac56883e8 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.10"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28395fa3e79..bd1f37d9714 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c33cf54af48..258b5b26a27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2312,7 +2312,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 1530139a6191d16231d2ca479f808e3452e93e32 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 1 Mar 2025 12:37:44 +0100 Subject: [PATCH 1376/1435] Bump aiowebdav2 to 0.3.1 (#139567) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 75a8d7ddfe2..b4950bc23f3 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.3.0"] + "requirements": ["aiowebdav2==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bd1f37d9714..bfb89dbd8d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 258b5b26a27..c2c3c99a64c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 From f17274d4179ba7ccd4da39f41826e1c300543bb3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:47:58 +0100 Subject: [PATCH 1377/1435] Validate scopes in SmartThings config flow (#139569) --- .../components/smartthings/config_flow.py | 2 + .../components/smartthings/strings.json | 3 +- .../smartthings/test_config_flow.py | 111 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index bcd2ddc192b..b39fe662124 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,6 +34,8 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" + if data[CONF_TOKEN]["scope"].split() != SCOPES: + return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) locations = await client.get_locations() diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index e5ffbe35e8b..9fd417284af 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -23,7 +23,8 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", - "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." } }, "entity": { diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 647e0ea5284..61e2b464920 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -101,6 +101,66 @@ async def test_full_flow( assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" +@pytest.mark.usefixtures("current_request_with_host") +async def test_not_enough_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if we don't have enough scopes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_duplicate_entry( hass: HomeAssistant, @@ -227,6 +287,57 @@ async def test_reauthentication( } +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_wrong_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong scopes.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_account_mismatch( hass: HomeAssistant, From a718b6ebff7fcee48f719ecc1e89f1476bbfada5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:08:28 +0100 Subject: [PATCH 1378/1435] Only determine SmartThings swing modes if we support it (#139571) Only determine swing modes if we support it --- homeassistant/components/smartthings/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2c3b8f3ac03..531b431f913 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -345,7 +345,8 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() - self._attr_swing_modes = self._determine_swing_modes() + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): + self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() def _determine_supported_features(self) -> ClimateEntityFeature: From 684c3aac6bffe93d56c58cdeec1d5905c69a82c0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 20:47:42 +0100 Subject: [PATCH 1379/1435] Don't require not needed scopes in SmartThings (#139576) * Don't require not needed scopes * Don't require not needed scopes --- homeassistant/components/smartthings/const.py | 2 -- .../smartthings/test_config_flow.py | 33 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index c39d225dd09..80c4cf90226 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -14,8 +14,6 @@ SCOPES = [ "x:scenes:*", "r:rules:*", "w:rules:*", - "r:installedapps", - "w:installedapps", "sse", ] diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 61e2b464920..2fbd686e4d3 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,7 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -75,8 +75,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -93,8 +92,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -130,7 +128,7 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -192,7 +190,7 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -210,8 +208,7 @@ async def test_duplicate_entry( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -261,8 +258,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -280,8 +276,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -377,8 +372,7 @@ async def test_reauth_account_mismatch( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -429,8 +423,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -461,8 +454,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -516,8 +508,7 @@ async def test_migration_wrong_location( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, From 74be49d00d8a5251aa7ea9634176e6aee63d1edf Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 1 Mar 2025 20:53:06 +0100 Subject: [PATCH 1380/1435] Homee: fix watchdog icon (#139577) fix watchdog icon --- homeassistant/components/homee/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 07ae598095b..17ac0ecd1f2 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -9,7 +9,7 @@ } }, "switch": { - "watchdog_on_off": { + "watchdog": { "default": "mdi:dog" }, "manual_operation": { From 511e57d0b3e5b2586d3e728a84bd91226ad04dab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:41:11 +0000 Subject: [PATCH 1381/1435] Bump aiohomekit to 3.2.8 (#139579) changelog: https://github.com/Jc2k/aiohomekit/compare/3.2.7...3.2.8 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index b7c82b9fd51..98db9a397d3 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.7"], + "requirements": ["aiohomekit==3.2.8"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bfb89dbd8d7..7031a2e6004 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,7 +267,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2c3c99a64c..934cf43bf3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From e766d681b540bba54dcb7e89e348a717b7d08567 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 2 Mar 2025 00:04:13 +0100 Subject: [PATCH 1382/1435] Fix duplicate unique id issue in Sensibo (#139582) * Fix duplicate unique id issue in Sensibo * Fixes * Mods --- .../components/sensibo/binary_sensor.py | 6 ++--- homeassistant/components/sensibo/button.py | 3 ++- homeassistant/components/sensibo/climate.py | 3 ++- .../components/sensibo/coordinator.py | 25 ++++++++++++++----- homeassistant/components/sensibo/number.py | 3 ++- homeassistant/components/sensibo/select.py | 3 ++- homeassistant/components/sensibo/sensor.py | 5 ++-- homeassistant/components/sensibo/switch.py | 3 ++- homeassistant/components/sensibo/update.py | 3 ++- tests/components/sensibo/test_coordinator.py | 4 +++ 10 files changed, 40 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 0d6c47ce46c..c7116db7954 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -130,9 +130,10 @@ async def async_setup_entry( """Handle additions of devices and sensors.""" entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( + new_devices, remove_devices, new_added_devices = coordinator.get_devices( added_devices ) + added_devices = new_added_devices if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug( @@ -168,8 +169,7 @@ async def async_setup_entry( device_data.model, DEVICE_SENSOR_TYPES ) ) - - async_add_entities(entities) + async_add_entities(entities) entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) _add_remove_devices() diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index ed0688d6f2c..d36967dae06 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -46,7 +46,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 2190d121248..906c4259ce5 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -149,7 +149,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e19f24295b9..3fa8a6e5dae 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -56,18 +56,31 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): ) -> tuple[set[str], set[str], set[str]]: """Addition and removal of devices.""" data = self.data - motion_sensors = { + current_motion_sensors = { sensor_id for device_data in data.parsed.values() if device_data.motion_sensors for sensor_id in device_data.motion_sensors } - devices: set[str] = set(data.parsed) - new_devices: set[str] = motion_sensors | devices - added_devices - remove_devices = added_devices - devices - motion_sensors - added_devices = (added_devices - remove_devices) | new_devices + current_devices: set[str] = set(data.parsed) + LOGGER.debug( + "Current devices: %s, moption sensors: %s", + current_devices, + current_motion_sensors, + ) + new_devices: set[str] = ( + current_motion_sensors | current_devices + ) - added_devices + remove_devices = added_devices - current_devices - current_motion_sensors + new_added_devices = (added_devices - remove_devices) | new_devices - return (new_devices, remove_devices, added_devices) + LOGGER.debug( + "New devices: %s, Removed devices: %s, Added devices: %s", + new_devices, + remove_devices, + new_added_devices, + ) + return (new_devices, remove_devices, new_added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 9d077b308a0..e71ed6f0235 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -76,7 +76,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 73c0734ef73..5a0546b1aa2 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -115,7 +115,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 4174d4b859b..09f095bfaec 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -253,9 +253,8 @@ async def async_setup_entry( entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( - added_devices - ) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: entities.extend( diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 8c140074e57..03e7c12ec2b 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -89,7 +89,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 2103bbbf64a..6f868e5f366 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -56,7 +56,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index 6cb8e6fe923..2d56fc4c51c 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pysensibo.exceptions import AuthenticationError, SensiboError from pysensibo.model import SensiboData +import pytest from homeassistant.components.climate import HVACMode from homeassistant.components.sensibo.const import DOMAIN @@ -25,6 +26,7 @@ async def test_coordinator( mock_client: MagicMock, get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the Sensibo coordinator with errors.""" config_entry = MockConfigEntry( @@ -87,3 +89,5 @@ async def test_coordinator( mock_data.assert_called_once() state = hass.states.get("climate.hallway") assert state.state == STATE_UNAVAILABLE + + assert "Platform sensibo does not generate unique IDs" not in caplog.text From 9055dff9bd8cfe27d9a404e7e0fac3f69ee40a18 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 20:16:32 +0100 Subject: [PATCH 1383/1435] Improve field descriptions of `zha.permit` action (#139584) Make the field descriptions of `source_ieee` and `install_code` UI-friendly by cross-referencing them using their friendly names to allow matching translations. Better explain the alternative of using the `qr_code` field by adding that this contains both the IEEE address and the Install code of the joining device. --- homeassistant/components/zha/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 38f55fb550d..be1642227bd 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -274,15 +274,15 @@ }, "source_ieee": { "name": "Source IEEE", - "description": "IEEE address of the joining device (must be used with the install code)." + "description": "IEEE address of the joining device (must be combined with the 'Install code' field)." }, "install_code": { "name": "Install code", - "description": "Install code of the joining device (must be used with the source_ieee)." + "description": "Install code of the joining device (must be combined with the 'Source IEEE' field)." }, "qr_code": { "name": "QR code", - "description": "Value of the QR install code (different between vendors)." + "description": "Provides both the IEEE address and the install code of the joining device (different between vendors)." } } }, From 8fdff9ca37fb980f5f0f3624e2d2e23506e6a482 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 19:35:39 +0100 Subject: [PATCH 1384/1435] Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` (#139585) * Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` * Improve comment --- .../components/mqtt/light/schema_json.py | 4 ++ tests/components/mqtt/test_light_json.py | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 14e21e61d48..4473385d550 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -217,6 +217,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = next(iter(self.supported_color_modes)) else: self._attr_color_mode = ColorMode.UNKNOWN + elif config.get(CONF_BRIGHTNESS): + # Brightness is supported and no supported_color_modes are set, + # so set brightness as the supported color mode. + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def _update_color(self, values: dict[str, Any]) -> None: color_mode: str = values["color_mode"] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7ddd04a09a6..bcf9d4bd736 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -361,6 +361,77 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + } + } + }, + ], +) +async def test_brightness_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test brightness only light. + + There are two possible configurations for brightness only light: + 1) Set up "brightness" as supported color mode. + 2) Set "brightness" flag to true. + """ + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.BRIGHTNESS + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness": 50}') + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + @pytest.mark.parametrize( "hass_config", [ From 6ff0f67d032f3bb67e3ea408e49ecb91035dd3d5 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 1 Mar 2025 19:22:34 +0000 Subject: [PATCH 1385/1435] Fix Manufacturer naming for Squeezelite model name for Squeezebox (#139586) Squeezelite Manufacturer Fix --- homeassistant/components/squeezebox/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0cd539b4584..1767d92730a 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -224,7 +224,7 @@ class SqueezeBoxMediaPlayerEntity( self._previous_media_position = 0 self._attr_unique_id = format_mac(player.player_id) _manufacturer = None - if player.model == "SqueezeLite" or "SqueezePlay" in player.model: + if player.model.startswith("SqueezeLite") or "SqueezePlay" in player.model: _manufacturer = "Ralph Irving" elif ( "Squeezebox" in player.model From c257b228f1b12869c3d63b013e97cdca2c31ea9b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 1 Mar 2025 23:05:55 +0100 Subject: [PATCH 1386/1435] Bump deebot-client to 12.3.1 (#139598) --- 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 b31fa7f347d..6d3dc5c9be6 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.2.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7031a2e6004..4ca19351b87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -756,7 +756,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 934cf43bf3b..c16c1e9b249 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -646,7 +646,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 74e8ffa5555e1da765d245979d5b3c8861d04051 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:13:04 -0600 Subject: [PATCH 1387/1435] Fix handling of NaN float values for current humidity in ESPHome (#139600) fixes #131837 --- homeassistant/components/esphome/climate.py | 9 +++++++-- tests/components/esphome/test_climate.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 478ce9bae2c..b651f16dfd7 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +from math import isfinite from typing import Any, cast from aioesphomeapi import ( @@ -238,9 +239,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @esphome_state_property def current_humidity(self) -> int | None: """Return the current humidity.""" - if not self._static_info.supports_current_humidity: + if ( + not self._static_info.supports_current_humidity + or (val := self._state.current_humidity) is None + or not isfinite(val) + ): return None - return round(self._state.current_humidity) + return round(val) @property @esphome_float_state_property diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 2a5013444dd..03d2f78a5d2 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -407,7 +407,7 @@ async def test_climate_entity_with_inf_value( target_temperature=math.inf, fan_mode=ClimateFanMode.AUTO, swing_mode=ClimateSwingMode.BOTH, - current_humidity=20.1, + current_humidity=math.inf, target_humidity=25.7, ) ] @@ -422,7 +422,7 @@ async def test_climate_entity_with_inf_value( assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes - assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert ATTR_CURRENT_HUMIDITY not in attributes assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 From 4fe4d14f16a0111249bdd3e67dc1987cb6144f61 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 01:12:19 +0200 Subject: [PATCH 1388/1435] Bump aioshelly to 13.1.0 (#139601) Co-authored-by: Franck Nijhof --- 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 ec08a005995..722fd4c128a 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==13.0.0"], + "requirements": ["aioshelly==13.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 4ca19351b87..1178fbd1e89 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==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c16c1e9b249..49167e3a800 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==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 From 3690e0395100157fd31a44835d00e02877f7cffc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 17:51:09 -0600 Subject: [PATCH 1389/1435] Bump inkbird-ble to 0.7.1 (#139603) changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.7.0...v0.7.1 --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 1a251f52582..acc7414edac 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -28,5 +28,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.7.0"] + "requirements": ["inkbird-ble==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1178fbd1e89..7165cd7362a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49167e3a800..dfeafe1a368 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 6abdb28a0396aa39491dd6370aac38042fa8b106 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 2 Mar 2025 05:00:22 +0100 Subject: [PATCH 1390/1435] Fix body text of imap message not available in custom event data template (#139609) --- homeassistant/components/imap/coordinator.py | 2 +- tests/components/imap/test_init.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 74f7a86c0d6..34d3f43eb69 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.custom_event_template is not None: try: data["custom"] = self.custom_event_template.async_render( - data, parse_result=True + data | {"text": message.text}, parse_result=True ) _LOGGER.debug( "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b86855bd78f..bdd29f7442b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -726,9 +726,10 @@ async def test_message_data( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), + ('{{ "body" in text }}', True, None), ("{% bad template }}", None, "Error rendering IMAP custom template"), ], - ids=["subject_test", "sender_filter", "template_error"], + ids=["subject_test", "sender_filter", "body_filter", "template_error"], ) async def test_custom_template( hass: HomeAssistant, From 7d9a6ceb6b302018b6b4ff63093d016cac9d9e8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 07:23:40 -0600 Subject: [PATCH 1391/1435] Fix arm vacation mode showing as armed away in elkm1 (#139613) Add native arm vacation mode support to elkm1 Vacation mode is currently implemented as a custom service which will be deprecated in a future PR. Note that the custom service was added long before HA had a native vacation mode which was added in #45980 --- homeassistant/components/elkm1/alarm_control_panel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 8113a4d99a6..393845f65ff 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -105,6 +105,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION ) _element: Area @@ -204,7 +205,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME, ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT, ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT, - ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY, + ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION, } if self._element.alarm_state is None: From 1d0cba1a43921c26dea9309cf897965c921c6371 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 14:17:56 +0100 Subject: [PATCH 1392/1435] Still request scopes in SmartThings (#139626) Still request scopes --- homeassistant/components/smartthings/config_flow.py | 4 ++-- homeassistant/components/smartthings/const.py | 6 ++++++ tests/components/smartthings/test_config_flow.py | 9 ++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index b39fe662124..0ad1b5553b1 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -11,7 +11,7 @@ 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 .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(SCOPES)} + return {"scope": " ".join(REQUESTED_SCOPES)} async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 80c4cf90226..23fd48a4e1e 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -17,6 +17,12 @@ SCOPES = [ "sse", ] +REQUESTED_SCOPES = [ + *SCOPES, + "r:installedapps", + "w:installedapps", +] + CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_INSTALLED_APP_ID = "installed_app_id" diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 2fbd686e4d3..858384db0b6 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,8 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -128,7 +129,8 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -190,7 +192,8 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() From 7e1309d8742a7491f04a4980bae57b1c5362f6fa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 13:15:43 +0100 Subject: [PATCH 1393/1435] Bump pysmartthings to 2.4.1 (#139627) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index e0cf6739290..7a25dc2ac13 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.4.0"] + "requirements": ["pysmartthings==2.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7165cd7362a..ffb7ead3bdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfeafe1a368..38f78484aad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 From 8382663be4b6d53fccd4de76650570840156795e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sun, 2 Mar 2025 16:15:38 +0100 Subject: [PATCH 1394/1435] Bump version to 2025.3.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 e295e6b3b91..895fcb1b3a6 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 = 3 -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 439cb650a6f..710b14869c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b2" +version = "2025.3.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c0dc83cbc01de3416265402fb90139cf9582036c Mon Sep 17 00:00:00 2001 From: cs12ag <70966712+cs12ag@users.noreply.github.com> Date: Mon, 3 Mar 2025 13:06:25 +0000 Subject: [PATCH 1395/1435] Fix unique identifiers where multiple IKEA Tradfri gateways are in use (#136060) * Create unique identifiers where multiple gateways are in use Resolving issue https://github.com/home-assistant/core/issues/134497 * Added migration function to __init__.py Added migration function to execute upon initialisation, to: a) remove the erroneously-added config)_entry added to the device (gateway B gets added as a config_entry to a device associated to gateway A), and b) swap out the non-unique identifiers for genuinely unique identifiers. * Added tests to simulate migration from bad data scenario (i.e. explicitly executing migrate_entity_unique_ids() from __init__.py) * Ammendments suggested in first review * Changes after second review * Rewrite of test_migrate_config_entry_and_identifiers after feedback * Converted migrate function into major version, updated tests * Finalised variable naming convention per feedback, added test to validate config entry migrated to v2 * Hopefully final changes for cosmetic / comment stucture * Further code-coverage in test_migrate_config_entry_and_identifiers() * Minor test corrections * Added test for non-tradfri identifiers --- homeassistant/components/tradfri/__init__.py | 94 ++++++++- .../components/tradfri/config_flow.py | 2 +- homeassistant/components/tradfri/entity.py | 2 +- tests/components/tradfri/__init__.py | 2 + tests/components/tradfri/test_init.py | 186 +++++++++++++++++- 5 files changed, 280 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 2073829e021..c3e8938b244 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -159,7 +159,7 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = {device.id for device in devices} + all_device_ids = {str(device.id) for device in devices} for device_entry in device_entries: device_id: str | None = None @@ -176,7 +176,7 @@ def remove_stale_devices( gateway_id = _id break - device_id = _id + device_id = _id.replace(f"{config_entry.data[CONF_GATEWAY_ID]}-", "") break if gateway_id is not None: @@ -190,3 +190,93 @@ def remove_stale_devices( device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry.entry_id ) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug( + "Migrating Tradfri configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + # Migrate to version 2 + migrate_config_entry_and_identifiers(hass, config_entry) + + hass.config_entries.async_update_entry(config_entry, version=2) + + LOGGER.debug( + "Migration to Tradfri configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + +def migrate_config_entry_and_identifiers( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old non-unique identifiers to new unique identifiers.""" + + related_device_flag: bool + device_id: str + + device_reg = dr.async_get(hass) + # Get all devices associated to contextual gateway config_entry + # and loop through list of devices. + for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): + related_device_flag = False + for identifier in device.identifiers: + if identifier[0] != DOMAIN: + continue + + related_device_flag = True + + _id = identifier[1] + + # Identify gateway device. + if _id == config_entry.data[CONF_GATEWAY_ID]: + # Using this to avoid updating gateway's own device registry entry + related_device_flag = False + break + + device_id = str(_id) + break + + # Check that device is related to tradfri domain (and is not the gateway itself) + if not related_device_flag: + continue + + # Loop through list of config_entry_ids for device + config_entry_ids = device.config_entries + for config_entry_id in config_entry_ids: + # Check that the config entry in list is not the device's primary config entry + if config_entry_id == device.primary_config_entry: + continue + + # Check that the 'other' config entry is also a tradfri config entry + other_entry = hass.config_entries.async_get_entry(config_entry_id) + + if other_entry is None or other_entry.domain != DOMAIN: + continue + + # Remove non-primary 'tradfri' config entry from device's config_entry_ids + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry_id + ) + + if config_entry.data[CONF_GATEWAY_ID] in device_id: + continue + + device_reg.async_update_device( + device.id, + new_identifiers={ + (DOMAIN, f"{config_entry.data[CONF_GATEWAY_ID]}-{device_id}") + }, + ) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 29d876346a7..9f5b39a9657 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -35,7 +35,7 @@ class AuthError(Exception): class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize flow.""" diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index b06d0081477..41c20b19de5 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -58,7 +58,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): info = self._device.device_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, + identifiers={(DOMAIN, f"{gateway_id}-{self._device_id}")}, manufacturer=info.manufacturer, model=info.model_number, name=self._device.name, diff --git a/tests/components/tradfri/__init__.py b/tests/components/tradfri/__init__.py index 37792ae7e32..f73d887d16c 100644 --- a/tests/components/tradfri/__init__.py +++ b/tests/components/tradfri/__init__.py @@ -1,4 +1,6 @@ """Tests for the tradfri component.""" GATEWAY_ID = "mock-gateway-id" +GATEWAY_ID1 = "mockgatewayid1" +GATEWAY_ID2 = "mockgatewayid2" TRADFRI_PATH = "homeassistant.components.tradfri" diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py index 54ce469f3c5..a1a4b8d9627 100644 --- a/tests/components/tradfri/test_init.py +++ b/tests/components/tradfri/test_init.py @@ -2,13 +2,19 @@ from unittest.mock import MagicMock +from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID +from pytradfri.gateway import Gateway + from homeassistant.components import tradfri +from homeassistant.components.tradfri.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component -from . import GATEWAY_ID +from . import GATEWAY_ID, GATEWAY_ID1, GATEWAY_ID2 +from .common import CommandStore -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def test_entry_setup_unload( @@ -66,6 +72,7 @@ async def test_remove_stale_devices( device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(tradfri.DOMAIN, "stale_device_id")}, + name="stale-device", ) device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id @@ -91,3 +98,178 @@ async def test_remove_stale_devices( assert device_entry.manufacturer == "IKEA of Sweden" assert device_entry.name == "Gateway" assert device_entry.model == "E1526" + + +async def test_migrate_config_entry_and_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + command_store: CommandStore, +) -> None: + """Test correction of device registry entries.""" + config_entry1 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host1", + tradfri.CONF_IDENTITY: "mock-identity1", + tradfri.CONF_KEY: "mock-key1", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID1, + }, + ) + + gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1) + command_store.register_device( + gateway1, load_json_object_fixture("bulb_w.json", DOMAIN) + ) + config_entry1.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host2", + tradfri.CONF_IDENTITY: "mock-identity2", + tradfri.CONF_KEY: "mock-key2", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID2, + }, + ) + + config_entry2.add_to_hass(hass) + + # Add non-tradfri config entry for use in testing negation logic + config_entry3 = MockConfigEntry( + domain="test_domain", + ) + + config_entry3.add_to_hass(hass) + + # Create gateway device for config entry 1 + gateway1_device = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(config_entry1.domain, config_entry1.data["gateway_id"])}, + name="Gateway", + ) + + # Create bulb 1 on gateway 1 in Device Registry - this has the old identifiers format + gateway1_bulb1 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, 65537)}, + name="bulb1", + ) + + # Update bulb 1 device to have both config entry IDs + # This is to simulate existing data scenario with older version of tradfri component + device_registry.async_update_device( + gateway1_bulb1.id, + add_config_entry_id=config_entry2.entry_id, + ) + + # Create bulb 2 on gateway 1 in Device Registry - this has the new identifiers format + gateway1_bulb2 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")}, + name="bulb2", + ) + + # Update bulb 2 device to have an additional config entry from config_entry3 + # This is to simulate scenario whereby a device entry + # is shared by multiple config entries + # and where at least one of those config entries is not the 'tradfri' domain + device_registry.async_update_device( + gateway1_bulb2.id, + add_config_entry_id=config_entry3.entry_id, + merge_identifiers={("test_domain", "config_entry_3-device2")}, + ) + + # Create a device on config entry 3 in Device Registry + config_entry3_device = device_registry.async_get_or_create( + config_entry_id=config_entry3.entry_id, + identifiers={("test_domain", "config_entry_3-device1")}, + name="device", + ) + + # Set up all tradfri config entries. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Validate that gateway 1 bulb 1 is still the same device entry + # This inherently also validates that the device's identifiers + # have been updated to the new unique format + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry1.entry_id + ) + assert ( + device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65537")} + ).id + == gateway1_bulb1.id + ) + + # Validate that gateway 1 bulb 1 only has gateway 1's config ID associated to it + # (Device at index 0 is the gateway) + assert device_entries[1].config_entries == {config_entry1.entry_id} + + # Validate that the gateway 1 device is unchanged + assert device_entries[0].id == gateway1_device.id + assert device_entries[0].identifiers == gateway1_device.identifiers + assert device_entries[0].config_entries == gateway1_device.config_entries + + # Validate that gateway 1 bulb 2 now only exists associated to config entry 3. + # The device will have had its identifiers updated to the new format (for the tradfri + # domain) per migrate_config_entry_and_identifiers(). + # The device will have then been removed from config entry 1 (gateway1) + # due to it not matching a device in the command store. + device_entry = device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")} + ) + + assert device_entry.id == gateway1_bulb2.id + # Assert that the only config entry associated to this device is config entry 3 + assert device_entry.config_entries == {config_entry3.entry_id} + # Assert that that device's other identifiers remain untouched + assert device_entry.identifiers == { + (tradfri.DOMAIN, f"{GATEWAY_ID1}-65538"), + ("test_domain", "config_entry_3-device2"), + } + + # Validate that gateway 2 bulb 1 has been added to device registry and with correct unique identifiers + # (This bulb device exists on gateway 2 because the command_store created above will be executed + # for each gateway being set up.) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry2.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[1].identifiers == {(tradfri.DOMAIN, f"{GATEWAY_ID2}-65537")} + + # Validate that gateway 2 bulb 1 only has gateway 2's config ID associated to it + assert device_entries[1].config_entries == {config_entry2.entry_id} + + # Validate that config entry 3 device 1 is still present, + # and has not had its config entries or identifiers changed + # N.B. The gateway1_bulb2 device will qualify in this set + # because the config entry 3 was added to it above + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry3.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[0].id == config_entry3_device.id + assert device_entries[0].identifiers == {("test_domain", "config_entry_3-device1")} + assert device_entries[0].config_entries == {config_entry3.entry_id} + + # Assert that the tradfri config entries have been migrated to v2 and + # the non-tradfri config entry remains at v1 + assert config_entry1.version == 2 + assert config_entry2.version == 2 + assert config_entry3.version == 1 + + +def mock_gateway_fixture(command_store: CommandStore, gateway_id: str) -> Gateway: + """Mock a Tradfri gateway.""" + gateway = Gateway() + command_store.register_response( + gateway.get_gateway_info(), + {ATTR_GATEWAY_ID: gateway_id, ATTR_FIRMWARE_VERSION: "1.2.1234"}, + ) + command_store.register_response( + gateway.get_devices(), + [], + ) + return gateway From 50aefc365335d03ef2451823cef4bacdc3f3d7fd Mon Sep 17 00:00:00 2001 From: Niklas Neesen Date: Sun, 2 Mar 2025 20:57:13 +0100 Subject: [PATCH 1396/1435] Fix vicare exception for specific ventilation device type (#138343) * fix for exception for specific ventilation device type + tests * fix for exception for specific ventilation device type + tests * New Testset just for fan * update test_sensor.ambr --- homeassistant/components/vicare/fan.py | 10 +- .../fixtures/Vitocal222G_Vitovent300W.json | 3019 +++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 126 + tests/components/vicare/test_climate.py | 4 +- tests/components/vicare/test_fan.py | 1 + 5 files changed, 3157 insertions(+), 3 deletions(-) create mode 100644 tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 26136260a4b..d84b2038dde 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -196,7 +196,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return False return self.percentage is not None and self.percentage > 0 @@ -209,7 +212,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: diff --git a/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json new file mode 100644 index 00000000000..a733d33a12a --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json @@ -0,0 +1,3019 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T20:58:18.395Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -15, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.4 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 8, + "modes": ["reduced", "normal", "fixed"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["dhw", "dhwAndHeating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "dhwAndHeating" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normal" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-02-11T20:49:01.456Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 33.2 + } + }, + "timestamp": "2025-02-11T19:48:05.380Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 44 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 4332.4 + }, + "starts": { + "type": "number", + "unit": "", + "value": 21314 + } + }, + "timestamp": "2025-02-11T20:34:55.482Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1.statistics", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-02-11T20:45:56.068Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.controller.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.controller.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.charging", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.charging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["5/25-cycles", "5/10-cycles", "on"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ] + } + } + }, + "timestamp": "2025-02-11T17:50:12.565Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.primary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.primary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["top", "normal", "temp-2"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.bottom", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": false, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 10, + "efficientUpperBorder": 60, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 50 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.temp2", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 60 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 6.9 + } + }, + "timestamp": "2025-02-11T20:58:31.054Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 5.2 + } + }, + "timestamp": "2025-02-11T20:48:38.307Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.9 + } + }, + "timestamp": "2025-02-11T20:46:37.502Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 1.9 + } + }, + "timestamp": "2025-02-11T21:00:13.154Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.5 + } + }, + "timestamp": "2025-02-11T20:48:00.474Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.cumulativeProduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.cumulativeProduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.production", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.pumps.circuit", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.pumps.circuit" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.collector", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["standby", "standard", "ventilation"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "ventilation" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.standard", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.standard" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelThree" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "ventilation" + }, + "level": { + "type": "string", + "value": "levelThree" + }, + "reason": { + "type": "string", + "value": "schedule" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "levelOne", + "maxEntries": 8, + "modes": ["levelTwo", "levelThree", "levelFour"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-01-12T22:36:28.706Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.2.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.name" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 0bac421e2c7..2c9e815f7bf 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -1,4 +1,69 @@ # 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': , + 'config_subentry_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({ @@ -62,3 +127,64 @@ 'state': 'off', }) # --- +# name: test_all_entities[fan.model2_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model2_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': 'gateway2_################-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model2_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model2 Ventilation', + 'icon': 'mdi:fan', + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model2_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index f48a8988cf0..9299f6567b1 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -23,7 +23,9 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index 5683f48f01f..8c42c92fb50 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -26,6 +26,7 @@ async def test_all_entities( fixtures: list[Fixture] = [ Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"), ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), From 0940fc78069d7657ab8dc858c985f666ffb5ecf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 4 Mar 2025 08:52:29 +0000 Subject: [PATCH 1397/1435] Prevent zero interval in Calendar get_events service (#139378) * Prevent zero interval in Calendar get_events service * Fix holiday calendar tests * Remove redundant entity_id * Use translation for exception * Replace check with voluptuous validator * Revert strings.xml --- homeassistant/components/calendar/__init__.py | 23 ++++++++ tests/components/calendar/test_init.py | 53 ++++++++++++++++++- tests/components/holiday/test_calendar.py | 12 ++--- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 40d6952fa64..96bf717c3ac 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -153,6 +153,27 @@ def _has_min_duration( return validate +def _has_positive_interval( + start_key: str, end_key: str, duration_key: str +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the time span between start and end is greater than zero.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + if (duration := obj.get(duration_key)) is not None: + if duration <= datetime.timedelta(seconds=0): + raise vol.Invalid(f"Expected positive duration ({duration})") + return obj + + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + if start >= end: + raise vol.Invalid( + f"Expected end time to be after start time ({start}, {end})" + ) + return obj + + return validate + + def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: """Verify that all values are of the same type.""" @@ -281,6 +302,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( ), } ), + _has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION), ) @@ -870,6 +892,7 @@ async def async_get_events_service( end = start + service_call.data[EVENT_DURATION] else: end = service_call.data[EVENT_END_DATETIME] + calendar_event_list = await calendar.async_get_events( calendar.hass, dt_util.as_local(start), dt_util.as_local(end) ) diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 2d712f408c2..6de0a7ef936 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import timedelta from http import HTTPStatus +import re from typing import Any from freezegun import freeze_time @@ -448,7 +449,7 @@ async def test_list_events_service( service: str, expected: dict[str, Any], ) -> None: - """Test listing events from the service call using exlplicit start and end time. + """Test listing events from the service call using explicit start and end time. This test uses a fixed date/time so that it can deterministically test the string output values. @@ -553,3 +554,53 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] +) +@pytest.mark.parametrize( + ("service_data", "error_msg"), + [ + ( + { + "start_date_time": "2023-06-22T04:30:00-06:00", + "end_date_time": "2023-06-22T04:30:00-06:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00-06:00, 2023-06-22 04:30:00-06:00)", + ), + ( + { + "start_date_time": "2023-06-22T04:30:00", + "end_date_time": "2023-06-22T04:30:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00, 2023-06-22 04:30:00)", + ), + ( + {"start_date_time": "2023-06-22", "end_date_time": "2023-06-22"}, + "Expected end time to be after start time (2023-06-22 00:00:00, 2023-06-22 00:00:00)", + ), + ( + {"start_date_time": "2023-06-22 10:00:00", "duration": "0"}, + "Expected positive duration (0:00:00)", + ), + ], +) +async def test_list_events_service_same_dates( + hass: HomeAssistant, + service_data: dict[str, str], + error_msg: str, +) -> None: + """Test listing events from the service call using the same start and end time.""" + + with pytest.raises(vol.error.MultipleInvalid, match=re.escape(error_msg)): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_EVENTS, + service_data={ + "entity_id": "calendar.calendar_1", + **service_data, + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index db58b7b1f73..6733d38442b 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -49,7 +49,7 @@ async def test_holiday_calendar_entity( SERVICE_GET_EVENTS, { "entity_id": "calendar.united_states_ak", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -135,7 +135,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -164,7 +164,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -211,7 +211,7 @@ async def test_no_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.albania", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -308,7 +308,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -336,7 +336,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, From b816625028df4b2b17d3816fd095d617341252a8 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 4 Mar 2025 11:40:36 +0100 Subject: [PATCH 1398/1435] Fix Homee brightness sensors reporting in percent (#139409) * fix brigtness sensor having percent as unit. * add test for percent-brightness-sensor * remove valve position and update tests * Removed test, because covered by Snapshots * fix review comments * move device calss to init. * fix test * fix review comments * add battery sensor back to test fixture * fix --- homeassistant/components/homee/icons.json | 6 ++ homeassistant/components/homee/sensor.py | 16 ++++++ homeassistant/components/homee/strings.json | 3 + tests/components/homee/fixtures/sensors.json | 23 +++++++- .../homee/snapshots/test_sensor.ambr | 55 ++++++++++++++++++- tests/components/homee/test_sensor.py | 28 +--------- 6 files changed, 103 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 17ac0ecd1f2..b4ad8871568 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "brightness": { + "default": "mdi:brightness-5" + }, + "brightness_instance": { + "default": "mdi:brightness-5" + }, "link_quality": { "default": "mdi:signal" }, diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 86733aae778..410f87f2168 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -40,10 +40,22 @@ def get_window_value(attribute: HomeeAttribute) -> str | None: return vals.get(attribute.current_value) +def get_brightness_device_class( + attribute: HomeeAttribute, device_class: SensorDeviceClass | None +) -> SensorDeviceClass | None: + """Return the device class for a brightness sensor.""" + if attribute.unit == "%": + return None + return device_class + + @dataclass(frozen=True, kw_only=True) class HomeeSensorEntityDescription(SensorEntityDescription): """A class that describes Homee sensor entities.""" + device_class_fn: Callable[ + [HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None + ] = lambda attribute, device_class: device_class value_fn: Callable[[HomeeAttribute], str | float | None] = ( lambda value: value.current_value ) @@ -67,6 +79,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { AttributeType.BRIGHTNESS: HomeeSensorEntityDescription( key="brightness", device_class=SensorDeviceClass.ILLUMINANCE, + device_class_fn=get_brightness_device_class, state_class=SensorStateClass.MEASUREMENT, value_fn=( lambda attribute: attribute.current_value * 1000 @@ -303,6 +316,9 @@ class HomeeSensor(HomeeEntity, SensorEntity): if attribute.instance > 0: self._attr_translation_key = f"{self._attr_translation_key}_instance" self._attr_translation_placeholders = {"instance": str(attribute.instance)} + self._attr_device_class = description.device_class_fn( + attribute, description.device_class + ) @property def native_value(self) -> float | str | None: diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index cf5b90dbe2a..94f85824280 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -111,6 +111,9 @@ } }, "sensor": { + "brightness": { + "name": "Illuminance" + }, "brightness_instance": { "name": "Illuminance {instance}" }, diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index f4a7f462218..bcc36a85ee7 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -81,6 +81,27 @@ "data": "", "name": "" }, + { + "id": 34, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, { "id": 4, "node_id": 1, @@ -93,7 +114,7 @@ "unit": "%", "step_value": 1.0, "editable": 0, - "type": 8, + "type": 11, "state": 1, "last_changed": 1709982926, "changed_by": 1, diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index 3101723232e..b35943630d5 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -82,8 +82,8 @@ 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': '00055511EECC-1-4', + 'translation_key': 'battery_instance', + 'unique_id': '00055511EECC-1-34', 'unit_of_measurement': '%', }) # --- @@ -518,6 +518,57 @@ 'state': '51.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance', + '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': 'Illuminance', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Illuminance', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index a2ba991c49b..bbdad4c4469 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.homee.const import ( WINDOW_MAP, WINDOW_MAP_REVERSED, ) -from homeassistant.const import LIGHT_LUX, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -37,7 +37,7 @@ async def test_up_down_values( assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[27] + attribute = mock_homee.nodes[0].attributes[28] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -69,7 +69,7 @@ async def test_window_position( == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[32] + attribute = mock_homee.nodes[0].attributes[33] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( @@ -87,28 +87,6 @@ async def test_window_position( ) -async def test_brightness_sensor( - hass: HomeAssistant, - mock_homee: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test brightness sensor's lx & klx units and naming of multi-instance sensors.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) - - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_1") - assert sensor_state.state == "175.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 1" - - # Sensor with Homee unit klx - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_2") - assert sensor_state.state == "7000.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 2" - - async def test_sensor_snapshot( hass: HomeAssistant, mock_homee: MagicMock, From 46bcb307f6a33ed636622d76ed6023058e7ee755 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 4 Mar 2025 09:56:42 +0100 Subject: [PATCH 1399/1435] Fix ability to remove orphan device in Music Assistant integration (#139431) * Fix ability to remove orphan device in Music Assistant integration * Add test * Remove orphaned device entries at startup as well * adjust mocked client --- .../components/music_assistant/__init__.py | 44 +++++++++++- tests/components/music_assistant/conftest.py | 16 +++++ tests/components/music_assistant/test_init.py | 70 +++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/components/music_assistant/test_init.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index e569bb93a42..a2d2dae9e3f 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion from music_assistant_models.enums import EventType -from music_assistant_models.errors import MusicAssistantError +from music_assistant_models.errors import ActionUnavailable, MusicAssistantError from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform @@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from .actions import register_actions +from .actions import get_music_assistant_client, register_actions from .const import DOMAIN, LOGGER if TYPE_CHECKING: @@ -137,6 +137,18 @@ async def async_setup_entry( mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) ) + # check if any playerconfigs have been removed while we were disconnected + all_player_configs = await mass.config.get_player_configs() + player_ids = {player.player_id for player in all_player_configs} + dev_reg = dr.async_get(hass) + dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) + for device in dev_entries: + for identifier in device.identifiers: + if identifier[0] == DOMAIN and identifier[1] not in player_ids: + dev_reg.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + return True @@ -174,3 +186,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await mass_entry_data.mass.disconnect() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + player_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if player_id is None: + # this should not be possible at all, but guard it anyways + return False + mass = get_music_assistant_client(hass, config_entry.entry_id) + if mass.players.get(player_id) is None: + # player is already removed on the server, this is an orphaned device + return True + # try to remove the player from the server + try: + await mass.config.remove_player_config(player_id) + except ActionUnavailable: + return False + else: + return True diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2df43defe62..2b397891d6f 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -8,6 +8,7 @@ from music_assistant_client.music import Music from music_assistant_client.player_queues import PlayerQueues from music_assistant_client.players import Players from music_assistant_models.api import ServerInfoMessage +from music_assistant_models.config_entries import PlayerConfig import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL @@ -68,6 +69,21 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.music = Music(client) client.server_url = client.server_info.base_url client.get_media_item_image_url = MagicMock(return_value=None) + client.config = MagicMock() + + async def get_player_configs() -> list[PlayerConfig]: + """Mock get player configs.""" + # simply return a mock config for each player + return [ + PlayerConfig( + values={}, + provider=player.provider, + player_id=player.player_id, + ) + for player in client.players + ] + + client.config.get_player_configs = get_player_configs yield client diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py new file mode 100644 index 00000000000..4cfefb50bd2 --- /dev/null +++ b/tests/components/music_assistant/test_init.py @@ -0,0 +1,70 @@ +"""Test the Music Assistant integration init.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from music_assistant_models.errors import ActionUnavailable + +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .common import setup_integration_from_fixtures + +from tests.typing import WebSocketGenerator + + +async def test_remove_config_entry_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await setup_integration_from_fixtures(hass, music_assistant_client) + await hass.async_block_till_done() + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + client = await hass_ws_client(hass) + + # test if the removal should be denied if the device is still in use + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.test_player_1" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock( + side_effect=ActionUnavailable + ) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 1 + assert response["success"] is False + + # test if the removal should be allowed if the device is not in use + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] is True + await hass.async_block_till_done() + assert not device_registry.async_get(device_entry.id) + assert not entity_registry.async_get(entity_id) + assert not hass.states.get(entity_id) + + # test if the removal succeeds if its no longer provided by the server + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players.pop(mass_player_id) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.my_super_test_player_2" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 0 + assert response["success"] is True From ad04b5361518f1afd980e1d4f2ba1d271e90f88a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:50:35 -0700 Subject: [PATCH 1400/1435] Fix broken link in ESPHome BLE repair (#139639) ESPHome always uses .0 in the URL for the changelog, and we never had a patch version in the stable BLE version field so we need to switch it to .0 for the URL. --- homeassistant/components/esphome/const.py | 4 +++- tests/components/esphome/test_manager.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index eb5f03c4495..20ff1cd27de 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -18,6 +18,8 @@ STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", } -DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" +# ESPHome always uses .0 for the changelog URL +STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" +DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy" diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index b805b065d5a..79653d3bb66 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -28,6 +28,7 @@ from homeassistant.components.esphome.const import ( CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, + STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) from homeassistant.const import ( @@ -365,7 +366,7 @@ async def test_esphome_device_with_old_bluetooth( ) assert ( issue.learn_more_url - == f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + == f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" ) From 03cb177e7c31a6f64963dfd660013e9367281a35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 19:52:37 +0100 Subject: [PATCH 1401/1435] Fix scope comparison in SmartThings (#139652) --- homeassistant/components/smartthings/config_flow.py | 2 +- tests/components/smartthings/test_config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 0ad1b5553b1..02b11b190c9 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,7 +34,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" - if data[CONF_TOKEN]["scope"].split() != SCOPES: + if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 858384db0b6..a16747c1190 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -261,7 +261,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -279,7 +279,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } From dca77e8232dd1f65c7124e7d0fb180b65c47e2ef Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Mar 2025 05:46:40 -0500 Subject: [PATCH 1402/1435] Avoid duplicate chat log content (#139679) --- homeassistant/components/conversation/chat_log.py | 6 +++++- tests/components/conversation/test_chat_log.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 1ee5e9965ab..19482af1983 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -49,7 +49,11 @@ def async_get_chat_log( raise RuntimeError( "Cannot attach chat log delta listener unless initial caller" ) - if user_input is not None: + if user_input is not None and ( + (content := chat_log.content[-1]).role != "user" + # MyPy doesn't understand that content is a UserContent here + or content.content != user_input.text # type: ignore[union-attr] + ): chat_log.async_add_user_content(UserContent(content=user_input.text)) yield chat_log diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index a4dc9b819c1..c0687ebecfb 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -86,7 +86,9 @@ async def test_default_content( with ( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log2, ): + assert chat_log is chat_log2 assert len(chat_log.content) == 2 assert chat_log.content[0].role == "system" assert chat_log.content[0].content == "" From 73cc1f51cac79a75292927883fa40199ad5714c0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:02:45 -0800 Subject: [PATCH 1403/1435] Add additional roborock debug logging (#139680) --- homeassistant/components/roborock/__init__.py | 1 + homeassistant/components/roborock/coordinator.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1c25d527aa8..955e50cd15b 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -65,6 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_user_agreement", ) from err except RoborockException as err: + _LOGGER.debug("Failed to get Roborock home data: %s", err) raise ConfigEntryNotReady( "Failed to get Roborock home data", translation_domain=DOMAIN, diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index b35f62323e8..6690b0ac07e 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -179,6 +179,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Get the rooms for that map id. await self.set_current_map_rooms() except RoborockException as ex: + _LOGGER.debug("Failed to update data: %s", ex) raise UpdateFailed(ex) from ex return self.roborock_device_info.props From 2c9b8b68353efc12846d788b1b8a763ec753e43d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:23:29 -0800 Subject: [PATCH 1404/1435] Improve failure handling and logging for invalid map responses (#139681) --- homeassistant/components/roborock/image.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 6d9e87b0556..66088d6453c 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Callable from datetime import datetime import io +import logging from roborock import RoborockCommand from vacuum_map_parser_base.config.color import ColorsPalette @@ -30,6 +31,8 @@ from .const import ( from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -48,7 +51,11 @@ async def async_setup_entry( ) def parse_image(map_bytes: bytes) -> bytes | None: - parsed_map = parser.parse(map_bytes) + try: + parsed_map = parser.parse(map_bytes) + except (IndexError, ValueError) as err: + _LOGGER.debug("Exception when parsing map contents: %s", err) + return None if parsed_map.image is None: return None img_byte_arr = io.BytesIO() @@ -150,6 +157,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): not isinstance(response[0], bytes) or (content := self.parser(response[0])) is None ): + _LOGGER.debug("Failed to parse map contents: %s", response[0]) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", From b890d3e15af707ef5bbd071fada115ada9e59451 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Mar 2025 20:07:07 +0100 Subject: [PATCH 1405/1435] Abort SmartThings flow if default_config is not enabled (#139700) * Abort SmartThings flow if default_config is not enabled * Abort SmartThings flow if default_config is not enabled * Abort SmartThings flow if default_config is not enabled --- .../components/smartthings/config_flow.py | 11 +++ .../components/smartthings/strings.json | 3 +- .../smartthings/test_config_flow.py | 82 +++++++++++++++++-- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 02b11b190c9..d2654348527 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -32,6 +32,17 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Extra data that needs to be appended to the authorize url.""" return {"scope": " ".join(REQUESTED_SCOPES)} + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check we have the cloud integration set up.""" + if "cloud" not in self.hass.config.components: + return self.async_abort( + reason="cloud_not_enabled", + description_placeholders={"default_config": "default_config"}, + ) + return await super().async_step_user(user_input) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9fd417284af..844ebd12004 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -24,7 +24,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", - "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions.", + "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml." } }, "entity": { diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index a16747c1190..7472d7d6b71 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -28,7 +28,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -@pytest.mark.usefixtures("current_request_with_host") +@pytest.fixture +def use_cloud(hass: HomeAssistant) -> None: + """Set up the cloud component.""" + hass.config.components.add("cloud") + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -100,7 +106,7 @@ async def test_full_flow( assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_not_enough_scopes( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -161,7 +167,7 @@ async def test_not_enough_scopes( assert result["reason"] == "missing_scopes" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -224,6 +230,23 @@ async def test_duplicate_entry( @pytest.mark.usefixtures("current_request_with_host") +async def test_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Check we abort when cloud is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -285,7 +308,7 @@ async def test_reauthentication( } -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauthentication_wrong_scopes( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -336,7 +359,7 @@ async def test_reauthentication_wrong_scopes( assert result["reason"] == "missing_scopes" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauth_account_mismatch( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -388,6 +411,29 @@ async def test_reauth_account_mismatch( @pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication without cloud.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -468,7 +514,7 @@ async def test_migration( assert mock_old_config_entry.minor_version == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration_wrong_location( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -539,3 +585,27 @@ async def test_migration_wrong_location( ) assert mock_old_config_entry.version == 3 assert mock_old_config_entry.minor_version == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" From c58cbfd6f42f49c2b38a3032902004d73e01cb1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 10:44:49 -0700 Subject: [PATCH 1406/1435] Bump ESPHome stable BLE version to 2025.2.2 (#139704) ensure proxies have https://github.com/esphome/esphome/pull/8328 so they do not reboot themselves if disconnecting takes too long --- homeassistant/components/esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 20ff1cd27de..1a3be4c34ae 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -13,7 +13,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False -STABLE_BLE_VERSION_STR = "2025.2.1" +STABLE_BLE_VERSION_STR = "2025.2.2" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 304c13261a77f9c96506826025c55065ba3c0eab Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 3 Mar 2025 20:47:38 +0100 Subject: [PATCH 1407/1435] Bump holidays to 0.68 (#139711) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index cd5ac1ec1a9..ec47b222370 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.67", "babel==2.15.0"] + "requirements": ["holidays==0.68", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index beb828641a4..cc6b0f30002 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.67"] + "requirements": ["holidays==0.68"] } diff --git a/requirements_all.txt b/requirements_all.txt index ffb7ead3bdf..693d398bfa0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.68 # homeassistant.components.frontend home-assistant-frontend==20250228.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38f78484aad..4c76efa2227 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -978,7 +978,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.68 # homeassistant.components.frontend home-assistant-frontend==20250228.0 From f1d332da5a843bf3741c5d85eff1b1b471acdd23 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 3 Mar 2025 22:26:16 +0200 Subject: [PATCH 1408/1435] Bump aiowebostv to 0.7.2 (#139712) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 06cbca32453..4632bbe8c74 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.1"], + "requirements": ["aiowebostv==0.7.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index 693d398bfa0..daebc1fc772 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.2 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c76efa2227..9bcc852cae8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.2 # homeassistant.components.withings aiowithings==3.1.6 From 1bdc33d52d90e69fe3762fb862d897b745f5588f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 14:20:25 -0700 Subject: [PATCH 1409/1435] Bump sense-energy to 0.13.6 (#139714) changes: https://github.com/scottbonline/sense/releases/tag/0.13.6 --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 384dd3556a9..d607372136c 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.6"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index a7cee28f9c9..dda49b661e5 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index daebc1fc772..fea719a795b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.6 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bcc852cae8..664da571369 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,7 +2173,7 @@ securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.6 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From a0dde2a7d663b41f1d369a0b682441c3f14368fc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:46:56 -0800 Subject: [PATCH 1410/1435] Add nest translation string for `already_in_progress` (#139727) --- homeassistant/components/nest/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 23da524ab7e..54f543aa845 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -58,6 +58,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", From 5b3d798ecab9c5c946f920a4ca6f43927af3c788 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:28:10 -0800 Subject: [PATCH 1411/1435] Bump google-nest-sdm to 7.1.4 (#139728) --- 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 a0d8bc06640..d9383533300 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.3"] + "requirements": ["google-nest-sdm==7.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fea719a795b..0530135ed07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1042,7 +1042,7 @@ google-cloud-texttospeech==2.17.2 google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 664da571369..976d7030a90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -892,7 +892,7 @@ google-cloud-texttospeech==2.17.2 google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From db63d9fcbf1f61d48366d0aa951645d11cb705dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 4 Mar 2025 11:07:44 +0100 Subject: [PATCH 1412/1435] Delete refresh after a non-breaking error at event stream at Home Connect (#139740) * Delete refresh after non-breaking error And improve how many time does it take to retry to open stream * Update tests --- .../components/home_connect/coordinator.py | 14 +++++------ .../home_connect/test_coordinator.py | 24 ++++--------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index d9200b282c9..4d275854e30 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -47,8 +47,6 @@ _LOGGER = logging.getLogger(__name__) type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] -EVENT_STREAM_RECONNECT_DELAY = 30 - @dataclass(frozen=True, kw_only=True) class HomeConnectApplianceData: @@ -157,9 +155,11 @@ class HomeConnectCoordinator( async def _event_listener(self) -> None: """Match event with listener for event type.""" + retry_time = 10 while True: try: async for event_message in self.client.stream_all_events(): + retry_time = 10 event_message_ha_id = event_message.ha_id match event_message.type: case EventType.STATUS: @@ -256,20 +256,18 @@ class HomeConnectCoordinator( except (EventStreamInterruptedError, HomeConnectRequestError) as error: _LOGGER.debug( "Non-breaking error (%s) while listening for events," - " continuing in 30 seconds", + " continuing in %s seconds", type(error).__name__, + retry_time, ) - await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + await asyncio.sleep(retry_time) + retry_time = min(retry_time * 2, 3600) except HomeConnectApiError as error: _LOGGER.error("Error while listening for events: %s", error) self.hass.config_entries.async_schedule_reload( self.config_entry.entry_id ) break - # if there was a non-breaking error, we continue listening - # but we need to refresh the data to get the possible changes - # that happened while the event stream was interrupted - await self.async_refresh() @callback def _call_event_listener(self, event_message: EventMessage) -> None: diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 3dd9ffbe7c1..ac27b848a36 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,6 +1,7 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -12,8 +13,6 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, - Status, - StatusKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -24,7 +23,6 @@ from aiohomeconnect.model.error import ( import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_PRESENT, BSH_POWER_OFF, @@ -38,8 +36,9 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -286,9 +285,6 @@ async def test_event_listener_error( ( "entity_id", "initial_state", - "status_key", - "status_value", - "after_refresh_expected_state", "event_key", "event_value", "after_event_expected_state", @@ -297,24 +293,15 @@ async def test_event_listener_error( ( "sensor.washer_door", "closed", - StatusKey.BSH_COMMON_DOOR_STATE, - BSH_DOOR_STATE_LOCKED, - "locked", EventKey.BSH_COMMON_STATUS_DOOR_STATE, BSH_DOOR_STATE_OPEN, "open", ), ], ) -@patch( - "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 -) async def test_event_listener_resilience( entity_id: str, initial_state: str, - status_key: StatusKey, - status_value: Any, - after_refresh_expected_state: str, event_key: EventKey, event_value: Any, after_event_expected_state: str, @@ -345,16 +332,13 @@ async def test_event_listener_resilience( assert hass.states.is_state(entity_id, initial_state) - client.get_status.return_value = ArrayOfStatus( - [Status(key=status_key, raw_key=status_key.value, value=status_value)], - ) await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert client.stream_all_events.call_count == 2 - assert hass.states.is_state(entity_id, after_refresh_expected_state) await client.add_events( [ From 6a5a66e2f9ca7675283c6ae867ba233a26babea1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Mar 2025 10:46:11 +0000 Subject: [PATCH 1413/1435] Bump version to 2025.3.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 895fcb1b3a6..2a3b2c082ae 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 = 3 -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 710b14869c8..06b5a433574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b3" +version = "2025.3.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c129f27c95753750987dd1c0a844d01228e68edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 4 Mar 2025 15:17:05 +0100 Subject: [PATCH 1414/1435] Bump aiohomeconnect to 0.16.2 (#139750) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 2f5ef4d1b37..5293e8bf468 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.1"], + "requirements": ["aiohomeconnect==0.16.2"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 0530135ed07..4eae0cb7588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.1 +aiohomeconnect==0.16.2 # homeassistant.components.homekit_controller aiohomekit==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 976d7030a90..029beb55cc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.1 +aiohomeconnect==0.16.2 # homeassistant.components.homekit_controller aiohomekit==3.2.8 From 185949cc185642fb37268687d66847d8793d2724 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:08:14 +0100 Subject: [PATCH 1415/1435] Add Apollo Automation virtual integration (#139751) Co-authored-by: Robert Resch --- homeassistant/components/apollo_automation/__init__.py | 1 + homeassistant/components/apollo_automation/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/apollo_automation/__init__.py create mode 100644 homeassistant/components/apollo_automation/manifest.json diff --git a/homeassistant/components/apollo_automation/__init__.py b/homeassistant/components/apollo_automation/__init__.py new file mode 100644 index 00000000000..7815b17818f --- /dev/null +++ b/homeassistant/components/apollo_automation/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Apollo Automation.""" diff --git a/homeassistant/components/apollo_automation/manifest.json b/homeassistant/components/apollo_automation/manifest.json new file mode 100644 index 00000000000..8e4c58f3f3d --- /dev/null +++ b/homeassistant/components/apollo_automation/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "apollo_automation", + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3185251114..e8fd68e2e24 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -345,6 +345,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "apollo_automation": { + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" + }, "appalachianpower": { "name": "Appalachian Power", "integration_type": "virtual", From a195a9107bacd374b55a8d307cf4aed1be0e2dd8 Mon Sep 17 00:00:00 2001 From: Anthony Hou Date: Tue, 4 Mar 2025 22:25:47 +0800 Subject: [PATCH 1416/1435] Fix incorrect weather state returned by HKO (#139757) * Fix incorrect weather state * Clean up unused import --------- Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/hko/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py index 5845e8831fe..aede960e702 100644 --- a/homeassistant/components/hko/coordinator.py +++ b/homeassistant/components/hko/coordinator.py @@ -11,7 +11,6 @@ from hko import HKO, HKOError from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, ATTR_CONDITION_LIGHTNING_RAINY, ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_POURING, @@ -145,7 +144,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Return the condition corresponding to the weather info.""" info = info.lower() if WEATHER_INFO_RAIN in info: - return ATTR_CONDITION_HAIL + return ATTR_CONDITION_RAINY if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info: return ATTR_CONDITION_SNOWY_RAINY if WEATHER_INFO_SNOW in info: From e73b08b269d1a69c6ef4cede9f235a0df8decd19 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:59:38 +0100 Subject: [PATCH 1417/1435] Bump pysmartthings to 2.5.0 (#139758) * Bump pysmartthings to 2.5.0 * Bump pysmartthings to 2.5.0 --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 7a25dc2ac13..22926e70ba0 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.4.1"] + "requirements": ["pysmartthings==2.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4eae0cb7588..ac4d06187ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.1 +pysmartthings==2.5.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 029beb55cc3..10c637eb92b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.1 +pysmartthings==2.5.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 47033e587bc843cabba3f44a060e6e22a477cf66 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 4 Mar 2025 19:26:20 +0100 Subject: [PATCH 1418/1435] Fix home connect available (#139760) * Fix home connect available * Extend and clarify test * Do not change connected state on stream interrupted --- .../components/home_connect/coordinator.py | 13 +- .../components/home_connect/entity.py | 16 ++- tests/components/home_connect/__init__.py | 18 +++ tests/components/home_connect/conftest.py | 21 +-- .../home_connect/test_coordinator.py | 132 +++++++++++++++++- 5 files changed, 177 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 4d275854e30..7898fb7be12 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -98,6 +98,7 @@ class HomeConnectCoordinator( CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] ] = {} self.device_registry = dr.async_get(self.hass) + self.data = {} @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -161,6 +162,14 @@ class HomeConnectCoordinator( async for event_message in self.client.stream_all_events(): retry_time = 10 event_message_ha_id = event_message.ha_id + if ( + event_message_ha_id in self.data + and not self.data[event_message_ha_id].info.connected + ): + self.data[event_message_ha_id].info.connected = True + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) match event_message.type: case EventType.STATUS: statuses = self.data[event_message_ha_id].status @@ -295,6 +304,8 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error except HomeConnectError as error: + for appliance_data in self.data.values(): + appliance_data.info.connected = False raise UpdateFailed( translation_domain=DOMAIN, translation_key="fetch_api_error", @@ -303,7 +314,7 @@ class HomeConnectCoordinator( return { appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) if self.data else None + appliance, self.data.get(appliance.ha_id) ) for appliance in appliances.homeappliances } diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 52eaaecace7..b55ff374f34 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -8,6 +8,7 @@ from typing import cast from aiohomeconnect.model import EventKey, OptionKey from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -51,8 +52,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self.update_native_value() + available = self._attr_available = self.appliance.info.connected self.async_write_ha_state() - _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) + state = STATE_UNAVAILABLE if not available else self.state + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, state) @property def bsh_key(self) -> str: @@ -61,10 +64,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): @property def available(self) -> bool: - """Return True if entity is available.""" - return ( - self.appliance.info.connected and self._attr_available and super().available - ) + """Return True if entity is available. + + Do not use self.last_update_success for available state + as event updates should take precedence over the coordinator + refresh. + """ + return self._attr_available class HomeConnectOptionEntity(HomeConnectEntity): diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 2b61501c59a..47a438fd218 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -1 +1,19 @@ """Tests for the Home Connect integration.""" + +from typing import Any + +from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus + +from tests.common import load_json_object_fixture + +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] # type: ignore[arg-type] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] # type: ignore[arg-type] +) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 49cbc89ba41..396fe8c5665 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,11 +11,9 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfCommands, ArrayOfEvents, - ArrayOfHomeAppliances, ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, - ArrayOfStatus, Event, EventKey, EventMessage, @@ -41,20 +39,15 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture - -MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( - load_json_object_fixture("home_connect/appliances.json")["data"] -) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") -MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") -MOCK_STATUS = ArrayOfStatus.from_dict( - load_json_object_fixture("home_connect/status.json")["data"] -) -MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( - "home_connect/available_commands.json" +from . import ( + MOCK_APPLIANCES, + MOCK_AVAILABLE_COMMANDS, + MOCK_PROGRAMS, + MOCK_SETTINGS, + MOCK_STATUS, ) +from tests.common import MockConfigEntry CLIENT_ID = "1234" CLIENT_SECRET = "5678" diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index ac27b848a36..1a49d2bb2a0 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,6 +1,7 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable +import copy from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -20,6 +21,7 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.home_connect.const import ( @@ -36,8 +38,11 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import MOCK_APPLIANCES + from tests.common import MockConfigEntry, async_fire_time_changed @@ -74,6 +79,123 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize("platforms", [("binary_sensor",)]) +@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True) +async def test_coordinator_failure_refresh_and_stream( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + client: MagicMock, + freezer: FrozenDateTimeFactory, + appliance_ha_id: str, +) -> None: + """Test entity available state via coordinator refresh and event stream.""" + entity_id_1 = "binary_sensor.washer_remote_control" + entity_id_2 = "binary_sensor.washer_remote_start" + await async_setup_component(hass, "homeassistant", {}) + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + client.get_home_appliances.side_effect = HomeConnectError() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Test that the entity becomes available again after a successful update. + + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # Move time forward to pass the debounce time. + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + # Test that the event stream makes the entity go available too. + + # First make the entity unavailable. + client.get_home_appliances.side_effect = HomeConnectError() + + # Move time forward to pass the debounce time + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Now make the entity available again. + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # One event should make all entities for this appliance available again. + event_message = EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + raw_key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE.value, + timestamp=0, + level="", + handling="", + value=False, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + @pytest.mark.parametrize( "mock_method", [ @@ -330,11 +452,13 @@ async def test_event_listener_resilience( assert config_entry.state == ConfigEntryState.LOADED assert len(config_entry._background_tasks) == 1 - assert hass.states.is_state(entity_id, initial_state) + state = hass.states.get(entity_id) + assert state + assert state.state == initial_state - await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -362,4 +486,6 @@ async def test_event_listener_resilience( ) await hass.async_block_till_done() - assert hass.states.is_state(entity_id, after_event_expected_state) + state = hass.states.get(entity_id) + assert state + assert state.state == after_event_expected_state From 7d82375f8185187c3ca5f8890c21c513c1f82c36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 07:01:40 -1000 Subject: [PATCH 1419/1435] Bump nexia to 2.1.1 (#139772) changelog: https://github.com/bdraco/nexia/compare/2.0.9...2.1.1 fixes #133368 --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 6a439f869c9..8a9cda14646 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.9"] + "requirements": ["nexia==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac4d06187ab..ee2708cdcd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.1.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10c637eb92b..0cfcd581b84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.1.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 01e8ca6495eb46e1df5c31f7ecf45823d8cf4e6f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Mar 2025 20:25:14 +0000 Subject: [PATCH 1420/1435] Bump version to 2025.3.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 2a3b2c082ae..0e7a9d0427d 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 = 3 -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 06b5a433574..41506b3de71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b4" +version = "2025.3.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bba889975ab7e2c116922edc3b77fddb64d87b80 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 5 Mar 2025 04:45:23 +0200 Subject: [PATCH 1421/1435] Bump aiowebostv to 0.7.3 (#139788) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 4632bbe8c74..8ac470ae922 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.2"], + "requirements": ["aiowebostv==0.7.3"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index ee2708cdcd3..0c4c22edb09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.2 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0cfcd581b84..8500ba955c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.2 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 From 08722432977439dc275d79876e6d2245cdd01507 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 5 Mar 2025 09:04:55 +0100 Subject: [PATCH 1422/1435] Drop BETA postfix from Matter integration's title (#139816) Drop BETA postfix from Matter title Now that the whole Matter stack of Home Assistant is officially certified, we can drop the beta flag. --- homeassistant/components/matter/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 669fa1af8c4..48f0bfa2e67 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -1,6 +1,6 @@ { "domain": "matter", - "name": "Matter (BETA)", + "name": "Matter", "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/matter"], "config_flow": true, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e8fd68e2e24..1f5a4d9d279 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3640,7 +3640,7 @@ "iot_class": "cloud_push" }, "matter": { - "name": "Matter (BETA)", + "name": "Matter", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" From b41fc932c594e04a86b58faac70679d465f66f4e Mon Sep 17 00:00:00 2001 From: SteveDiks <126147459+SteveDiks@users.noreply.github.com> Date: Wed, 5 Mar 2025 10:11:59 +0100 Subject: [PATCH 1423/1435] Split the energy and data retrieval in WeHeat (#139211) * Split the energy and data logs * Make sure that pump_info name is set to device name, bump weheat * Adding config entry * Fixed circular import * parallelisation of awaits * Update homeassistant/components/weheat/binary_sensor.py Co-authored-by: Joost Lekkerkerker * Fix undefined weheatdata --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/weheat/__init__.py | 41 +++++- .../components/weheat/binary_sensor.py | 19 +-- homeassistant/components/weheat/const.py | 3 +- .../components/weheat/coordinator.py | 118 ++++++++++++++---- homeassistant/components/weheat/entity.py | 17 ++- homeassistant/components/weheat/manifest.json | 2 +- homeassistant/components/weheat/sensor.py | 98 ++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 223 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index b67c3540dc5..15935f3e418 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from http import HTTPStatus import aiohttp @@ -18,7 +19,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .const import API_URL, LOGGER -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatData, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -52,14 +59,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo except UnauthorizedException as error: raise ConfigEntryAuthFailed from error + nr_of_pumps = len(discovered_heat_pumps) + for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) - # for each pump, add a coordinator - new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info) + # for each pump, add the coordinators - await new_coordinator.async_config_entry_first_refresh() + new_heat_pump = HeatPumpInfo(pump_info) + new_data_coordinator = WeheatDataUpdateCoordinator( + hass, entry, session, pump_info, nr_of_pumps + ) + new_energy_coordinator = WeheatEnergyUpdateCoordinator( + hass, entry, session, pump_info + ) - entry.runtime_data.append(new_coordinator) + entry.runtime_data.append( + WeheatData( + heat_pump_info=new_heat_pump, + data_coordinator=new_data_coordinator, + energy_coordinator=new_energy_coordinator, + ) + ) + + await asyncio.gather( + *[ + data.data_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + *[ + data.energy_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 6a4a03a1e48..5e4c91fde60 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import HeatPumpInfo, WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -68,10 +68,14 @@ async def async_setup_entry( ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ - WeheatHeatPumpBinarySensor(coordinator, entity_description) + WeheatHeatPumpBinarySensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for weheatdata in entry.runtime_data for entity_description in BINARY_SENSORS - for coordinator in entry.runtime_data - if entity_description.value_fn(coordinator.data) is not None + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None ] async_add_entities(entities) @@ -80,20 +84,21 @@ async def async_setup_entry( class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity): """Defines a Weheat heat pump binary sensor.""" + heat_pump_info: HeatPumpInfo coordinator: WeheatDataUpdateCoordinator entity_description: WeHeatBinarySensorEntityDescription def __init__( self, + heat_pump_info: HeatPumpInfo, coordinator: WeheatDataUpdateCoordinator, entity_description: WeHeatBinarySensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index e33fd983572..ee9b77281e6 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -17,7 +17,8 @@ API_URL = "https://api.weheat.nl" OAUTH2_SCOPES = ["openid", "offline_access"] -UPDATE_INTERVAL = 30 +LOG_UPDATE_INTERVAL = 120 +ENERGY_UPDATE_INTERVAL = 1800 LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index d7e53258e9b..30ca61d0387 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -1,5 +1,6 @@ """Define a custom coordinator for the Weheat heatpump integration.""" +from dataclasses import dataclass from datetime import timedelta from weheat.abstractions.discovery import HeatPumpDiscovery @@ -10,6 +11,7 @@ from weheat.exceptions import ( ForbiddenException, NotFoundException, ServiceException, + TooManyRequestsException, UnauthorizedException, ) @@ -21,7 +23,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL +from .const import API_URL, DOMAIN, ENERGY_UPDATE_INTERVAL, LOG_UPDATE_INTERVAL, LOGGER + +type WeheatConfigEntry = ConfigEntry[list[WeheatData]] EXCEPTIONS = ( ServiceException, @@ -29,9 +33,43 @@ EXCEPTIONS = ( ForbiddenException, BadRequestException, ApiException, + TooManyRequestsException, ) -type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + +class HeatPumpInfo(HeatPumpDiscovery.HeatPumpInfo): + """Heat pump info with additional properties.""" + + def __init__(self, pump_info: HeatPumpDiscovery.HeatPumpInfo) -> None: + """Initialize the HeatPump object with the provided pump information. + + Args: + pump_info (HeatPumpDiscovery.HeatPumpInfo): An object containing the heat pump's discovery information, including: + - uuid (str): Unique identifier for the heat pump. + - uuid (str): Unique identifier for the heat pump. + - device_name (str): Name of the heat pump device. + - model (str): Model of the heat pump. + - sn (str): Serial number of the heat pump. + - has_dhw (bool): Indicates if the heat pump has domestic hot water functionality. + + """ + super().__init__( + pump_info.uuid, + pump_info.device_name, + pump_info.model, + pump_info.sn, + pump_info.has_dhw, + ) + + @property + def readable_name(self) -> str | None: + """Return the readable name of the heat pump.""" + return self.device_name if self.device_name else self.model + + @property + def heatpump_id(self) -> str: + """Return the heat pump id.""" + return self.uuid class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): @@ -45,45 +83,28 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): config_entry: WeheatConfigEntry, session: OAuth2Session, heat_pump: HeatPumpDiscovery.HeatPumpInfo, + nr_of_heat_pumps: int, ) -> None: """Initialize the data coordinator.""" super().__init__( hass, - logger=LOGGER, config_entry=config_entry, + logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + update_interval=timedelta(seconds=LOG_UPDATE_INTERVAL * nr_of_heat_pumps), ) - self.heat_pump_info = heat_pump self._heat_pump_data = HeatPump( API_URL, heat_pump.uuid, async_get_clientsession(hass) ) self.session = session - @property - def heatpump_id(self) -> str: - """Return the heat pump id.""" - return self.heat_pump_info.uuid - - @property - def readable_name(self) -> str | None: - """Return the readable name of the heat pump.""" - if self.heat_pump_info.name: - return self.heat_pump_info.name - return self.heat_pump_info.model - - @property - def model(self) -> str: - """Return the model of the heat pump.""" - return self.heat_pump_info.model - async def _async_update_data(self) -> HeatPump: """Fetch data from the API.""" await self.session.async_ensure_token_valid() try: - await self._heat_pump_data.async_get_status( + await self._heat_pump_data.async_get_logs( self.session.token[CONF_ACCESS_TOKEN] ) except UnauthorizedException as error: @@ -92,3 +113,54 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): raise UpdateFailed(error) from error return self._heat_pump_data + + +class WeheatEnergyUpdateCoordinator(DataUpdateCoordinator[HeatPump]): + """A custom Energy coordinator for the Weheat heatpump integration.""" + + config_entry: WeheatConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WeheatConfigEntry, + session: OAuth2Session, + heat_pump: HeatPumpDiscovery.HeatPumpInfo, + ) -> None: + """Initialize the data coordinator.""" + super().__init__( + hass, + config_entry=config_entry, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=ENERGY_UPDATE_INTERVAL), + ) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) + + self.session = session + + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + + try: + await self._heat_pump_data.async_get_energy( + self.session.token[CONF_ACCESS_TOKEN] + ) + except UnauthorizedException as error: + raise ConfigEntryAuthFailed from error + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + return self._heat_pump_data + + +@dataclass +class WeheatData: + """Data for the Weheat integration.""" + + heat_pump_info: HeatPumpInfo + data_coordinator: WeheatDataUpdateCoordinator + energy_coordinator: WeheatEnergyUpdateCoordinator diff --git a/homeassistant/components/weheat/entity.py b/homeassistant/components/weheat/entity.py index 079db596e19..7a12b2edcfa 100644 --- a/homeassistant/components/weheat/entity.py +++ b/homeassistant/components/weheat/entity.py @@ -3,25 +3,30 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import HeatPumpInfo from .const import DOMAIN, MANUFACTURER -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatDataUpdateCoordinator, WeheatEnergyUpdateCoordinator -class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]): +class WeheatEntity[ + _WeheatEntityT: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator +](CoordinatorEntity[_WeheatEntityT]): """Defines a base Weheat entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: _WeheatEntityT, ) -> None: """Initialize the Weheat entity.""" super().__init__(coordinator) + self.heat_pump_info = heat_pump_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.heatpump_id)}, - name=coordinator.readable_name, + identifiers={(DOMAIN, heat_pump_info.heatpump_id)}, + name=heat_pump_info.readable_name, manufacturer=MANUFACTURER, - model=coordinator.model, + model=heat_pump_info.model, ) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index a408303d062..7297c601213 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.2.22"] + "requirements": ["weheat==2025.2.26"] } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 615bfd30d18..d3b758e41eb 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -27,7 +27,12 @@ from .const import ( DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -142,22 +147,6 @@ SENSORS = [ else None ), ), - WeHeatSensorEntityDescription( - translation_key="electricity_used", - key="electricity_used", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda status: status.energy_total, - ), - WeHeatSensorEntityDescription( - translation_key="energy_output", - key="energy_output", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda status: status.energy_output, - ), WeHeatSensorEntityDescription( translation_key="compressor_rpm", key="compressor_rpm", @@ -174,7 +163,6 @@ SENSORS = [ ), ] - DHW_SENSORS = [ WeHeatSensorEntityDescription( translation_key="dhw_top_temperature", @@ -196,6 +184,25 @@ DHW_SENSORS = [ ), ] +ENERGY_SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="electricity_used", + key="electricity_used", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_total, + ), + WeHeatSensorEntityDescription( + translation_key="energy_output", + key="energy_output", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_output, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -203,17 +210,39 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" - entities = [ - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in SENSORS - for coordinator in entry.runtime_data - ] - entities.extend( - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in DHW_SENSORS - for coordinator in entry.runtime_data - if coordinator.heat_pump_info.has_dhw - ) + + entities: list[WeheatHeatPumpSensor] = [] + for weheatdata in entry.runtime_data: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None + ) + if weheatdata.heat_pump_info.has_dhw: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in DHW_SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) + is not None + ) + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.energy_coordinator, + entity_description, + ) + for entity_description in ENERGY_SENSORS + if entity_description.value_fn(weheatdata.energy_coordinator.data) + is not None + ) async_add_entities(entities) @@ -221,20 +250,21 @@ async def async_setup_entry( class WeheatHeatPumpSensor(WeheatEntity, SensorEntity): """Defines a Weheat heat pump sensor.""" - coordinator: WeheatDataUpdateCoordinator + heat_pump_info: HeatPumpInfo + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator entity_description: WeHeatSensorEntityDescription def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator, entity_description: WeHeatSensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def native_value(self) -> StateType: diff --git a/requirements_all.txt b/requirements_all.txt index 0c4c22edb09..10dda4e324a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3058,7 +3058,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.2.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8500ba955c0..866d850c5d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2462,7 +2462,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.2.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 From 7001f8daaf4e26e6722f527ecee77d71efdcea72 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 09:39:26 +0000 Subject: [PATCH 1424/1435] Bump version to 2025.3.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 0e7a9d0427d..79c831a3033 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 = 3 -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 41506b3de71..a5c1c55fa3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b5" +version = "2025.3.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2c2fd76270e353cc14c4eed0062c49d8f7afc3b1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Mar 2025 11:54:58 +0100 Subject: [PATCH 1425/1435] Update frontend to 20250305.0 (#139829) --- 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 d8eb53467f0..e661439cff2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250228.0"] + "requirements": ["home-assistant-frontend==20250305.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 54401a12592..790180691c0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 10dda4e324a..f972f4adb57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 866d850c5d5..1e6c7814426 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 5043e2ad108f78c6a5cdce7d4a9d541d5440718e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 11:01:06 +0000 Subject: [PATCH 1426/1435] Bump version to 2025.3.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 79c831a3033..b861e9e7170 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 = 3 -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 a5c1c55fa3c..38a144806a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b6" +version = "2025.3.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2812c8a9930309ab2b957f6e8047cb7ef229c117 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 5 Mar 2025 20:13:11 +0900 Subject: [PATCH 1427/1435] Get temperature data appropriate for hass.config.unit in LG ThinQ (#137626) * Get temperature data appropriate for hass.config.unit * Modify temperature_unit for init * Modify unit's map * Fix ruff error --------- Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 9 ++++- homeassistant/components/lg_thinq/const.py | 9 +++++ .../components/lg_thinq/coordinator.py | 40 ++++++++++++++++++- homeassistant/components/lg_thinq/entity.py | 10 +---- .../lg_thinq/snapshots/test_climate.ambr | 16 ++++---- tests/components/lg_thinq/test_climate.py | 3 +- 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 73678e209f7..98a86a8d355 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -110,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.OFF] self._attr_hvac_mode = HVACMode.OFF self._attr_preset_modes = [] - self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) self._requested_hvac_mode: str | None = None # Set up HVAC modes. @@ -182,6 +184,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_target_temperature_high = self.data.target_temp_high self._attr_target_temperature_low = self.data.target_temp_low + # Update unit. + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + _LOGGER.debug( "[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s", self.coordinator.device_name, diff --git a/homeassistant/components/lg_thinq/const.py b/homeassistant/components/lg_thinq/const.py index a65dee715db..20c6455241a 100644 --- a/homeassistant/components/lg_thinq/const.py +++ b/homeassistant/components/lg_thinq/const.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Final +from homeassistant.const import UnitOfTemperature + # Config flow DOMAIN = "lg_thinq" COMPANY = "LGE" @@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1) # MQTT: Message types DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH" DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS" + +# Unit conversion map +DEVICE_UNIT_TO_HA: dict[str, str] = { + "F": UnitOfTemperature.FAHRENHEIT, + "C": UnitOfTemperature.CELSIUS, +} +REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()} diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index d6991d15297..513cd27a7b2 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from thinqconnect import ThinQAPIException from thinqconnect.integration import HABridge -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_CORE_CONFIG_UPDATE +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: from . import ThinqConfigEntry -from .const import DOMAIN +from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA _LOGGER = logging.getLogger(__name__) @@ -54,6 +56,40 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id ) + # Set your preferred temperature unit. This will allow us to retrieve + # temperature values from the API in a converted value corresponding to + # preferred unit. + self._update_preferred_temperature_unit() + + # Add a callback to handle core config update. + self.unit_system: str | None = None + self.hass.bus.async_listen( + event_type=EVENT_CORE_CONFIG_UPDATE, + listener=self._handle_update_config, + event_filter=self.async_config_update_filter, + ) + + async def _handle_update_config(self, _: Event) -> None: + """Handle update core config.""" + self._update_preferred_temperature_unit() + + await self.async_refresh() + + @callback + def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool: + """Filter out unwanted events.""" + if (unit_system := event_data.get("unit_system")) != self.unit_system: + self.unit_system = unit_system + return True + + return False + + def _update_preferred_temperature_unit(self) -> None: + """Update preferred temperature unit.""" + self.api.set_preferred_temperature_unit( + REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit) + ) + async def _async_update_data(self) -> dict[str, Any]: """Request to the server to update the status from full response data.""" try: diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 7856506559b..61d8199f321 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState -from homeassistant.const import UnitOfTemperature from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COMPANY, DOMAIN +from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN from .coordinator import DeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) EMPTY_STATE = PropertyState() -UNIT_CONVERSION_MAP: dict[str, str] = { - "F": UnitOfTemperature.FAHRENHEIT, - "C": UnitOfTemperature.CELSIUS, -} - class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """The base implementation of all lg thinq entities.""" @@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): if unit is None: return None - return UNIT_CONVERSION_MAP.get(unit) + return DEVICE_UNIT_TO_HA.get(unit) def _update_status(self) -> None: """Update status itself. diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index db57e824487..111d49a2ef3 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -15,8 +15,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_modes': list([ 'air_clean', ]), @@ -28,7 +28,7 @@ 'on', 'off', ]), - 'target_temp_step': 1, + 'target_temp_step': 2, }), 'config_entry_id': , 'config_subentry_id': , @@ -62,7 +62,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 40, - 'current_temperature': 25, + 'current_temperature': 77, 'fan_mode': 'mid', 'fan_modes': list([ 'low', @@ -75,8 +75,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_mode': None, 'preset_modes': list([ 'air_clean', @@ -94,8 +94,8 @@ ]), 'target_temp_high': None, 'target_temp_low': None, - 'target_temp_step': 1, - 'temperature': 19, + 'target_temp_step': 2, + 'temperature': 66, }), 'context': , 'entity_id': 'climate.test_air_conditioner', diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 24ed3ad230d..4ac2fa55a21 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,6 +23,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) From 1484e46317726218336acfc43e7354eaed7da29c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 14:57:03 -1000 Subject: [PATCH 1428/1435] Bump nexia to 2.2.1 (#139786) * Bump nexia to 2.2.0 changelog: https://github.com/bdraco/nexia/compare/2.1.1...2.2.0 * Apply suggestions from code review --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 8a9cda14646..337378a283c 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.1.1"] + "requirements": ["nexia==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f972f4adb57..19d93b4927b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.1.1 +nexia==2.2.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e6c7814426..30754158426 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.1.1 +nexia==2.2.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 3f94b7a61c9514f39fc0bf99608d7b7ec14dac19 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 5 Mar 2025 05:36:20 -0800 Subject: [PATCH 1429/1435] Revert "Add scene support to roborock (#137203)" (#139840) This reverts commit 379bf106754dffd5c6c8cd8035a33597976cd866. --- homeassistant/components/roborock/__init__.py | 24 +--- homeassistant/components/roborock/const.py | 1 - .../components/roborock/coordinator.py | 49 +------- homeassistant/components/roborock/scene.py | 64 ---------- tests/components/roborock/conftest.py | 23 +--- tests/components/roborock/mock_data.py | 17 --- tests/components/roborock/test_scene.py | 112 ------------------ 7 files changed, 12 insertions(+), 278 deletions(-) delete mode 100644 homeassistant/components/roborock/scene.py delete mode 100644 tests/components/roborock/test_scene.py diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 955e50cd15b..c382a56cde7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -83,13 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> # Get a Coordinator if the device is available or if we have connected to the device before coordinators = await asyncio.gather( *build_setup_functions( - hass, - entry, - device_map, - user_data, - product_info, - home_data.rooms, - api_client, + hass, entry, device_map, user_data, product_info, home_data.rooms ), return_exceptions=True, ) @@ -141,7 +135,6 @@ def build_setup_functions( user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, @@ -158,7 +151,6 @@ def build_setup_functions( device, product_info[device.product_id], home_data_rooms, - api_client, ) for device in device_map.values() ] @@ -171,12 +163,11 @@ async def setup_device( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: """Set up a coordinator for a given device.""" if device.pv == "1.0": return await setup_device_v1( - hass, entry, user_data, device, product_info, home_data_rooms, api_client + hass, entry, user_data, device, product_info, home_data_rooms ) if device.pv == "A01": return await setup_device_a01(hass, entry, user_data, device, product_info) @@ -196,7 +187,6 @@ async def setup_device_v1( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | None: """Set up a device Coordinator.""" mqtt_client = await hass.async_add_executor_job( @@ -218,15 +208,7 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, - entry, - device, - networking, - product_info, - mqtt_client, - home_data_rooms, - api_client, - user_data, + hass, entry, device, networking, product_info, mqtt_client, home_data_rooms ) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index fe9091a3ea7..cc8d34fbadc 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -36,7 +36,6 @@ PLATFORMS = [ Platform.BUTTON, Platform.IMAGE, Platform.NUMBER, - Platform.SCENE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6690b0ac07e..806651c9ac5 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,26 +10,17 @@ import logging from propcache.api import cached_property from roborock import HomeDataRoom from roborock.code_mappings import RoborockCategory -from roborock.containers import ( - DeviceData, - HomeDataDevice, - HomeDataProduct, - HomeDataScene, - NetworkInfo, - UserData, -) +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.roborock_typing import DeviceProp from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 -from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType @@ -76,8 +67,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): product_info: HomeDataProduct, cloud_api: RoborockMqttClientV1, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, - user_data: UserData, ) -> None: """Initialize.""" super().__init__( @@ -100,7 +89,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, - identifiers={(DOMAIN, self.duid)}, + identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, model_id=self.roborock_device_info.product.model, @@ -114,10 +103,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} self.map_storage = RoborockMapStorage( - hass, self.config_entry.entry_id, self.duid_slug + hass, self.config_entry.entry_id, slugify(self.duid) ) - self._user_data = user_data - self._api_client = api_client async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -147,7 +134,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", - self.duid, + self.roborock_device_info.device.duid, ) await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. @@ -207,34 +194,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for room in room_mapping or () } - async def get_scenes(self) -> list[HomeDataScene]: - """Get scenes.""" - try: - return await self._api_client.get_scenes(self._user_data, self.duid) - except RoborockException as err: - _LOGGER.error("Failed to get scenes %s", err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "get_scenes", - }, - ) from err - - async def execute_scene(self, scene_id: int) -> None: - """Execute scene.""" - try: - await self._api_client.execute_scene(self._user_data, scene_id) - except RoborockException as err: - _LOGGER.error("Failed to execute scene %s %s", scene_id, err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "execute_scene", - }, - ) from err - @cached_property def duid(self) -> str: """Get the unique id of the device as specified by Roborock.""" diff --git a/homeassistant/components/roborock/scene.py b/homeassistant/components/roborock/scene.py deleted file mode 100644 index ff418a2810c..00000000000 --- a/homeassistant/components/roborock/scene.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Support for Roborock scene.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from homeassistant.components.scene import Scene as SceneEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator -from .entity import RoborockEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RoborockConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up scene platform.""" - scene_lists = await asyncio.gather( - *[coordinator.get_scenes() for coordinator in config_entry.runtime_data.v1], - ) - async_add_entities( - RoborockSceneEntity( - coordinator, - EntityDescription( - key=str(scene.id), - name=scene.name, - ), - ) - for coordinator, scenes in zip( - config_entry.runtime_data.v1, scene_lists, strict=True - ) - for scene in scenes - ) - - -class RoborockSceneEntity(RoborockEntity, SceneEntity): - """A class to define Roborock scene entities.""" - - entity_description: EntityDescription - - def __init__( - self, - coordinator: RoborockDataUpdateCoordinator, - entity_description: EntityDescription, - ) -> None: - """Create a scene entity.""" - super().__init__( - f"{entity_description.key}_{coordinator.duid_slug}", - coordinator.device_info, - coordinator.api, - ) - self._scene_id = int(entity_description.key) - self._coordinator = coordinator - self.entity_description = entity_description - - async def async_activate(self, **kwargs: Any) -> None: - """Activate the scene.""" - await self._coordinator.execute_scene(self._scene_id) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 9b3a6633c62..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -30,7 +30,6 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, - SCENES, USER_DATA, USER_EMAIL, ) @@ -68,24 +67,8 @@ class A01Mock(RoborockMqttClientA01): return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols} -@pytest.fixture(name="bypass_api_client_fixture") -def bypass_api_client_fixture() -> None: - """Skip calls to the API client.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=HOME_DATA, - ), - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - return_value=SCENES, - ), - ): - yield - - @pytest.fixture(name="bypass_api_fixture") -def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: +def bypass_api_fixture() -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), @@ -93,6 +76,10 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" ), + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=HOME_DATA, + ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", return_value=NETWORK_INFO, diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 59c54892687..6e3fb229aa9 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -9,7 +9,6 @@ from roborock.containers import ( Consumable, DnDTimer, HomeData, - HomeDataScene, MultiMapsList, NetworkInfo, S7Status, @@ -1151,19 +1150,3 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) - - -SCENES = [ - HomeDataScene.from_dict( - { - "name": "sc1", - "id": 12, - }, - ), - HomeDataScene.from_dict( - { - "name": "sc2", - "id": 24, - }, - ), -] diff --git a/tests/components/roborock/test_scene.py b/tests/components/roborock/test_scene.py deleted file mode 100644 index 15707784feb..00000000000 --- a/tests/components/roborock/test_scene.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Test Roborock Scene platform.""" - -from unittest.mock import ANY, patch - -import pytest -from roborock import RoborockException - -from homeassistant.const import SERVICE_TURN_ON, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError - -from tests.common import MockConfigEntry - - -@pytest.fixture -def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: - """Fixture to raise when getting scenes.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - side_effect=RoborockException(), - ), - ): - yield - - -@pytest.mark.parametrize( - ("entity_id"), - [ - ("scene.roborock_s7_maxv_sc1"), - ("scene.roborock_s7_maxv_sc2"), - ], -) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_get_scenes_failure( - hass: HomeAssistant, - bypass_api_client_get_scenes_fixture, - setup_entry: MockConfigEntry, - entity_id: str, -) -> None: - """Test that if scene retrieval fails, no entity is being created.""" - # Ensure that the entity does not exist - assert hass.states.get(entity_id) is None - - -@pytest.fixture -def platforms() -> list[Platform]: - """Fixture to set platforms used in the test.""" - return [Platform.SCENE] - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ("scene.roborock_s7_maxv_sc2", 24), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_success( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test activating the scene entities.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene" - ) as mock_execute_scene: - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_failure( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test failure while activating the scene entity.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene", - side_effect=RoborockException, - ) as mock_execute_scene, - pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), - ): - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" From 8056b0df2b6939ec2b47f182569ef15f6d0d60a6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 17:04:41 +0100 Subject: [PATCH 1430/1435] Bump aioecowitt to 2025.3.1 (#139841) * Bump aioecowitt to 2025.3.1 * Bump aioecowitt to 2025.3.1 --- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 175960ab57d..3ce66f48f95 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2024.2.1"] + "requirements": ["aioecowitt==2025.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 19d93b4927b..7172befba9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30754158426..0b99fa05ccf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 diff --git a/script/licenses.py b/script/licenses.py index aa15a58f3bd..448e9dd2a67 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -180,7 +180,6 @@ EXCEPTIONS = { "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 - "aioecowitt", # https://github.com/home-assistant-libs/aioecowitt/pull/180 "chacha20poly1305", # LGPL "commentjson", # https://github.com/vaidik/commentjson/pull/55 "crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5 From 6c080ee650d4ef19ccef227b964ba453be6ec1d9 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Mar 2025 16:19:15 +0100 Subject: [PATCH 1431/1435] Bump onedrive-personal-sdk to 0.0.13 (#139846) --- 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 31a1f2ccb06..c3d98200b03 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": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.12"] + "requirements": ["onedrive-personal-sdk==0.0.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7172befba9c..58e717d79c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.13 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b99fa05ccf..73d5d27503a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.13 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From b88eab8ba35daeb899e11aa3240c7f3290bcd98c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Mar 2025 11:20:48 -0600 Subject: [PATCH 1432/1435] Bump intents to 2025.3.5 (#139851) Co-authored-by: Franck Nijhof --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index c4f1860eed6..ea950ace323 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.3", "home-assistant-intents==2025.2.26"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 790180691c0..f74bc88bc56 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250305.0 -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 58e717d79c6..c0cea94142b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ holidays==0.68 home-assistant-frontend==20250305.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73d5d27503a..82e49f43bda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.68 home-assistant-frontend==20250305.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1f177643bd5..37de7857915 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 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.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 51162320cbf65f7d9e75fda940d81aa4da9c963c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 17:25:33 +0000 Subject: [PATCH 1433/1435] Bump version to 2025.3.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 b861e9e7170..da281567f85 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 = 3 -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 38a144806a3..86e700a46ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b7" +version = "2025.3.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ed088aa72fad267f0a68f9a6db862a1244c1841a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 17:39:36 +0000 Subject: [PATCH 1434/1435] Bump version to 2025.3.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 da281567f85..da2c3268642 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 = 3 -PATCH_VERSION: Final = "0b8" +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 86e700a46ce..3f80f7c8ead 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b8" +version = "2025.3.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 98e317dd5560e1168f0db5b0b1ec6331ef903bad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 18:41:21 +0100 Subject: [PATCH 1435/1435] Fix no disabled capabilities in SmartThings (#139860) Fix no disabled capabilities --- homeassistant/components/smartthings/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d6de1d3d252..f7f3d628c20 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -174,11 +174,12 @@ def process_status( list[Capability | str], disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, ) - for capability in disabled_capabilities: - # We still need to make sure the climate entity can work without this capability - if ( - capability in main_component - and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL - ): - del main_component[capability] + if disabled_capabilities is not None: + for capability in disabled_capabilities: + # We still need to make sure the climate entity can work without this capability + if ( + capability in main_component + and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + ): + del main_component[capability] return status